Compare commits

...

114 Commits
2.1.1 ... main

Author SHA1 Message Date
dependabot[bot] 2178580a75 build(deps): bump golangci/golangci-lint-action from 5 to 6
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 5 to 6.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:59:04 -07:00
dependabot[bot] b52f61cc77 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.20 to 2.20.21
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.20 to 2.20.21.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.20...v2.20.21)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:58:41 -07:00
dependabot[bot] 3388f8e376 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [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.19.0...v1.19.1)

---
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-05-09 21:37:53 -07:00
dependabot[bot] 83ceb20c1c Bump golang.org/x/net from 0.24.0 to 0.25.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/net/compare/v0.24.0...v0.25.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-05-06 16:45:02 -07:00
dependabot[bot] c06850ca34 Bump golang.org/x/oauth2 from 0.19.0 to 0.20.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.19.0...v0.20.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-05-06 16:22:00 -07:00
dependabot[bot] d856c02fbb Bump golang.org/x/crypto from 0.22.0 to 0.23.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/crypto/compare/v0.22.0...v0.23.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-05-06 15:42:12 -07:00
Jan-Lukas Else a33b1adf13 Add description field to feed settings
This adds a new "description" field to the feed settings. This allows to
save custom description regarding a feed. It is also exported and
imported as "description" in OPML.
2024-05-06 15:40:36 -07:00
fin444 a631bd527d options: add FETCH_NEBULA_WATCH_TIME 2024-05-02 16:30:01 -07:00
Alpha Chen ca62b0b36b integration/raindrop: initial draft implementation 2024-05-02 16:23:00 -07:00
Kioubit 7d6a4243c1 Make cookie duration dependent on configuration
This ensures that session cookies are not expiring before the session is cleaned up from the database as per CLEANUP_REMOVE_SESSIONS_DAYS.
As of now the usefulness of this configuration option is diminished as extending it has no effect on the actual browser session due to the cookie expiry.
Fixes: #2214
2024-05-01 19:34:13 -07:00
dependabot[bot] d056aa1f73 Bump github.com/PuerkitoBio/goquery from 1.9.1 to 1.9.2
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.9.1 to 1.9.2.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.1...v1.9.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-30 17:23:35 -07:00
dependabot[bot] 018e24404e Bump golangci/golangci-lint-action from 4 to 5
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 4 to 5.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 16:51:20 -07:00
Frédéric Guillot 4d3ee0d15d ci: fix docker workflow to add distroless suffix on latest tag 2024-04-27 15:26:16 -07:00
Frédéric Guillot 797450986b Update ChangeLog 2024-04-27 15:06:28 -07:00
Ztec 93bc9ce24d add seek and speed controls to media player
When listening to podcast, it is usual to want to speed up the playback.
https://github.com/miniflux/v2/pull/2521 was addressing the need globally, this PR
allow to address it for just the current open enclosure media. (no save) Some Browser
already include this control directly, but firefox does not (directly anyway).

Also, it is often useful to be able to skip chunk of a podcast, to skip commercials
for example, or get back a bit because we couldn't hear the last part. I added rudimentary
seek controls with the usual +/-10 and 30 seconds chuck size. This is pretty handy when podcast
are very long and using the seek bar is way too tricky to just skip 30s.

As always, I'm French and could only provide English and French translation for the few
text I added in the locale/translations files. Any help is welcome.

Tested mostly on Firefox (121.0) and quickly on Vivaldi(6.5.3206.53), chrome based.

Fixes: #1845 #1846
2024-04-26 13:44:26 -07:00
dependabot[bot] 9233568da3 Bump github.com/tdewolff/minify/v2 from 2.20.19 to 2.20.20
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.19 to 2.20.20.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.19...v2.20.20)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 19:09:18 -07:00
Frédéric Guillot fb075b60b5 reader/processor: minifier is breaking HTML entry content 2024-04-23 20:31:52 -07:00
Frédéric Guillot 2c4c845cd2 http/response: add brotli compression support 2024-04-19 12:16:49 -07:00
bo0tzz 2caabbe939 fix: Use `FORCE_REFRESH_INTERVAL` config for category refresh 2024-04-19 11:58:13 -07:00
Frédéric Guillot 771f9d2b5f reader/fetcher: add brotli content encoding support 2024-04-19 10:50:46 -07:00
Romain de Laage 647c66e70a ui: add tag entries page 2024-04-14 20:08:38 -07:00
jvoisin b205b5aad0 reader/processor: minimize the feed's entries html
Compress the html of feed entries before storing it. This should reduce the
size of the database a bit, but more importantly, reduce the amount of data
sent to clients

minify being [stupidly fast](https://github.com/tdewolff/minify/?tab=readme-ov-file#performance), the performance impact should be in the noise level.
2024-04-10 19:48:48 -07:00
goodfirm 4ab0d9422d chore: fix function name in comment
Signed-off-by: goodfirm <fanyishang@yeah.net>
2024-04-10 19:36:30 -07:00
Frédéric Guillot 38b80d96ea storage: change GetReadTime() function to use entries_feed_id_hash_key index 2024-04-09 20:37:30 -07:00
Michael Kuhn 35edd8ea92 Fix clicking unread counter
When clicking the unread counter, the following exception occurs:
```
Uncaught TypeError: Cannot read properties of null (reading 'getAttribute')
```

This is due to `onClickMainMenuListItem` not working correctly for the
unread counter `span`s, which return `null` when using `querySelector`.
2024-04-09 20:36:42 -07:00
Alexandros Kosiaris f0cb041885 Add back removed other repo owners in GH docker actions
In cf96ab45c1, support was added for using Docker related Github
actions in repositories of other owners. This was pretty helpful as it
allowed running modified forks off of main in a nightly fashion before
patches were pushed upstream. This was 6e870cdccc, add it
back
2024-04-06 11:31:29 -07:00
Frédéric Guillot fdd1b3f18e database: entry URLs can exceeds btree index size limit 2024-04-04 20:22:23 -07:00
Frédéric Guillot 6e870cdccc ci: use docker/metadata-action instead of deprecated shell-scripts 2024-04-04 18:04:32 -07:00
Michael Kuhn 194f517be8 Improve Dockerfiles
- Specify Docker registry explicitly (e.g., Podman does not use
  `docker.io` by default)
- Use `make miniflux` instead of duplicating `go build` arguments (this
  leverages Go's PIE build mode)
- Enable cgo to fix ARM containers (we need to make sure to use the same
  OS version for both container stages to avoid libc issues)
2024-04-04 17:36:28 -07:00
dependabot[bot] 11fd1c935e Bump golang.org/x/net from 0.23.0 to 0.24.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.24.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-04-04 16:37:41 -07:00
dependabot[bot] 47e1111908 Bump golang.org/x/term from 0.18.0 to 0.19.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/term/compare/v0.18.0...v0.19.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-04-04 16:08:16 -07:00
dependabot[bot] c5b812eb7b Bump golang.org/x/oauth2 from 0.18.0 to 0.19.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.18.0...v0.19.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-04-04 15:46:40 -07:00
dependabot[bot] 53be550e8a Bump github.com/yuin/goldmark from 1.7.0 to 1.7.1
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.0...v1.7.1)

---
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-04-03 17:30:47 -07:00
dependabot[bot] d0d693a6ef Bump golang.org/x/net from 0.22.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.22.0...v0.23.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-04-03 17:30:26 -07:00
Evan Elias Young 1b8c45d162 finder: Find feed from YouTube playlist
The feed from a YouTube playlist page is derived in practically the same way as a feed from a YouTube channel page.
2024-04-01 21:16:32 -07:00
jvoisin 19ce519836 reader/rewrite: add a rule for oglaf.com
By default, Oglaf show some disclaimer/warning about its content, and this
doesn't play well with rss readers, so let's rewrite it to show the actual
comic instead of a placeholder.
2024-04-01 21:05:01 -07:00
Thomas J Faughnan Jr 3e0d5de7a3 api tests: use intSize-agnostic random integers
rand.Intn(math.MaxInt64) causes tests to fail on 32-bit architectures.
Use the simpler rand.Int() instead, which still provides plenty of room
for generating pseudo-random test usernames.
2024-04-01 21:02:48 -07:00
Frédéric Guillot 0336774e8c Update ChangeLog 2024-03-30 14:39:41 -07:00
Jean Khawand 756dd449cc
integration/webhook: add category title to request body 2024-03-29 16:37:05 -07:00
Taylan Tatlı a0b4665080 Turkish Translation Update 2024-03-28 19:09:24 -07:00
dependabot[bot] 6592c1ad6b Bump dominikh/staticcheck-action from 1.3.0 to 1.3.1
Bumps [dominikh/staticcheck-action](https://github.com/dominikh/staticcheck-action) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/dominikh/staticcheck-action/releases)
- [Changelog](https://github.com/dominikh/staticcheck-action/blob/master/CHANGES.md)
- [Commits](https://github.com/dominikh/staticcheck-action/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: dominikh/staticcheck-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 17:02:21 -07:00
jvoisin f109e3207c reader/rss: don't add empty tags to RSS items
This commit adds a bunch of checks to prevent reader/rss from adding empty tags
to rss items, as well as some minor refactors like nested conditions and loops
unrolling.
2024-03-24 19:46:56 -07:00
Romain de Laage b54fe66809 fix: do not store empty tags 2024-03-24 14:50:18 -07:00
jvoisin 93c9d43497 http/response: get rid of the X-XSS-Protection header
It's useless at best, dangerous at worst, and shouldn't be used anymore
anywhere. See the following resources for details:

- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
- https://chromestatus.com/feature/5021976655560704
- https://bugzilla.mozilla.org/show_bug.cgi?id=528661
- https://blogs.windows.com/windows-insider/2018/07/25/announcing-windows-10-insider-preview-build-17723-and-build-18204/
2024-03-24 13:45:38 -07:00
Frédéric Guillot e3b3c40c28 timezone: make sure the tests pass when the timezone database is not installed on the host 2024-03-24 13:25:02 -07:00
Frédéric Guillot 068790fc19 integration: fix rssbrige import 2024-03-24 12:42:29 -07:00
Frédéric Guillot 41d99c517f Update GitHub PR template 2024-03-23 14:34:03 -07:00
Frédéric Guillot 3db3f9884f cli: avoid misleading error message when creating an admin user 2024-03-23 14:32:55 -07:00
Frédéric Guillot ad1d349a0c rss: use Channel tags only if there is no Item tags 2024-03-23 13:46:48 -07:00
Jean Khawand 7ee4a731af Update miniflux.1 2024-03-21 19:59:02 -07:00
Jean Khawand 3c822a45ac Update miniflux.1
#2187  #2543
2024-03-21 19:59:02 -07:00
Frédéric Guillot c2311e316c Rename PROXY_* options to MEDIA_PROXY_* 2024-03-20 21:28:28 -07:00
jvoisin ed20771194 Enable trusted-types
This commit adds a policy, and make use of it in the Content-Security-Policy.

I've tested it the best I could, both on a modern browser supporting
trusted-types (Chrome) and on one that doesn't (firefox).

Thanks to @lweichselbaum for giving me a hand to wrap this up!
2024-03-20 17:50:37 -07:00
jvoisin beb8c80787 Replace a bunch of `let` with `const`
According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const

> Many style guides (including MDN's) recommend using const over let whenever a
variable is not reassigned in its scope. This makes the intent clear that a
variable's type (or value, in the case of a primitive) can never change.
2024-03-20 17:36:01 -07:00
jvoisin fc4bdf3ab0 Inline a one-liner function
No need to expose a symbol for this.
2024-03-20 17:21:30 -07:00
Frédéric Guillot 6bc819e198 man page: sort config options in alphabetical order 2024-03-19 22:22:24 -07:00
Frédéric Guillot 08640b27d5 Ensure enclosure URLs are always absolute 2024-03-19 21:57:46 -07:00
jvoisin 4be993e055 Minor refactoring of internal/reader/atom/atom_10_adapter.go
- Move the population of the feed's entries into a new function, to make
  `BuildFeed` easier to understand/separate concerns/implementation details
- Use `sort+compact` instead of `compact+sort` to remove duplicates
- Change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}`.
  This reduce the number of comparisons, but also improves a tad the
  control-flow readability.
2024-03-19 20:41:44 -07:00
jvoisin 9df12177eb Minor idiomatic pass on internal/http/request/context.go 2024-03-19 20:21:23 -07:00
Jean Khawand a78d1c79da
Add `FILTER_ENTRY_MAX_AGE_DAYS` config option to limit fetching all feed items 2024-03-20 02:58:53 +00:00
Matt Behrens 1ea3953271
Add keyboard shortcuts for scrolling to top/bottom of the item list 2024-03-19 19:30:38 -07:00
dependabot[bot] fe8b7a907e Bump github.com/coreos/go-oidc/v3 from 3.9.0 to 3.10.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.9.0...v3.10.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-19 19:09:08 -07:00
Frédéric Guillot a15cdb1655 Fix regression in AbsoluteProxifyURL()
Regression introduced in commit 66b8483791

PR #2499
2024-03-18 20:48:20 -07:00
Frédéric Guillot fa9697b972 Remove trailing space in SiteURL and FeedURL 2024-03-18 17:51:06 -07:00
jvoisin 8e28e41b02 Use struct embedding to reduce code duplication 2024-03-18 16:23:44 -07:00
jvoisin e2ee74428a Minor concatenation-related simplifications in internal/storage/
Use plain strings concatenation instead of
building an array and then joining it.
2024-03-18 16:20:55 -07:00
jvoisin 863a5b3648 Simplify removeDuplicates
Use a sort+compact construct instead of doing it by hand with a hashmap. The
time complexity is now O(nlogn+n) instead of O(n), and space complexity around
O(logn) instead of O(n+uniq(n)), but it shouldn't matter anyway, since
removeDuplicates is only called to deduplicate tags.
2024-03-18 16:13:32 -07:00
jvoisin 91f5522ce0 Minor simplification of internal/reader/media/media.go
- Simplify a switch-case by moving a common condition above it.
- Remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed
  an empty string.
2024-03-18 16:09:32 -07:00
Frédéric Guillot 8212f16aa2 atom: avoid debug message when the date is empty 2024-03-17 15:29:50 -07:00
Frédéric Guillot b1e73fafdf Enable go-critic linter and fix various issues detected 2024-03-17 13:52:34 -07:00
Frédéric Guillot f6404290ba Replace Optional{Int,Int64,Float64} with a generic function OptionalNumber() 2024-03-17 12:25:55 -07:00
jvoisin c29ca0e313 Minor simplifications of the rewriter
- Online some one-line functions
- Transform a free-standing function into a method
- Massively simplify `removeClickbait`
- Use a proper constant instead of a magic number in `applyFuncOnTextContent`
2024-03-17 12:15:46 -07:00
jvoisin 02a074ed26 Compile block/keep regex only once per feed
No need to compile them once for matching on the url,
once per tag, once per title, once per author, … one time is enough.
It also simplify error handling, since while regexp compilation can fail,
matching can't.
2024-03-17 12:08:03 -07:00
Romain de Laage 00dabc1d3c feat: Media player: Conrol playback speed
fix  #1845
2024-03-17 11:53:30 -07:00
Frédéric Guillot b68ada396a Rewrite API integration tests without build tags 2024-03-16 21:29:07 -07:00
Frédéric Guillot e299e821a6 Update GitHub PR template 2024-03-15 20:59:17 -07:00
Frédéric Guillot 0f17dfc7d6 Fix regressions introduced by PR #2476
'Toast' messages are broken and v hotkey opens in the same tab

Commit d25c032171
2024-03-15 20:55:32 -07:00
Frédéric Guillot 7c80d6b86d Fix download button loading label 2024-03-15 20:40:14 -07:00
Frédéric Guillot f6f63b5282 Avoid warnings in ui package
Remove unused variables and improve JSON decoding in
saveEnclosureProgression()
2024-03-15 19:49:39 -07:00
Frédéric Guillot 309fdbb9fc Fix force refresh 2024-03-15 19:42:09 -07:00
Frédéric Guillot e2d862f2f6 Display an error message on edit feed page when the feed URL is not unique 2024-03-15 19:07:54 -07:00
Frédéric Guillot 4834e934f2 Remove some duplicated code in RSS parser 2024-03-15 18:40:06 -07:00
Frédéric Guillot dd4fb660c1 Refactor Atom parser to use an adapter 2024-03-15 17:27:16 -07:00
jvoisin 2ba893bc79 Bump the number of simultaneous workers
We're in 2024, I'm pretty sure we can afford to have 16 simultaneous open http
connections at the same time, instead of only 5.
2024-03-15 14:05:58 -07:00
Frédéric Guillot 7a307f8e74 Fix regression: Add to Home Screen button is unreadable
Regression introduced in commit ea58bac548
2024-03-14 17:37:50 -07:00
jvoisin 7310e13499 More trusted-types compatibility 2024-03-14 17:10:40 -07:00
dependabot[bot] bf6d286735 Bump github.com/go-webauthn/webauthn from 0.10.1 to 0.10.2
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.10.1 to 0.10.2.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.10.1...v0.10.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-14 17:03:54 -07:00
Frédéric Guillot ca919c2ff8 Fix JavaScript error on the login page 2024-03-13 21:47:23 -07:00
Frédéric Guillot 5948786b15 Add support for RSS <media:category> element 2024-03-13 21:35:39 -07:00
jvoisin f4746a7306 Fix and simplify shaarli's integration
- The jwt token was declared as using HS256 as algorithm, but was using HS512.
- No need to base64-encode then remove the padding when we can simply encode
  without padding.
- Factorize the header+payload concatenation as data

Odds are that this integration was broken from the start (HS512 vs HS256), so
I'm not sure if it's better to add tests or to simply get rid of it.
2024-03-13 21:34:57 -07:00
Frédéric Guillot 648b9a8f6f Refactor RSS Parser to use an adapter 2024-03-13 21:25:09 -07:00
jvoisin 66b8483791 Minor simplification of internal/proxy/proxy.go
- re-use ProxifiedUrl to implement AbsoluteProxifyURL, reducing the copy-pasta
- reduce the internal indentation of ProxifiedUrl by inverting some conditions
2024-03-13 19:42:01 -07:00
jvoisin e0ee28c013 More progress towards trusted-types
Create a new function `addIcon` and use it to add icons, instead of
operating on raw html.
2024-03-13 19:35:20 -07:00
dependabot[bot] d862d86f90 Bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-13 18:31:50 -07:00
jvoisin d25c032171 Simplify bootstrap.js
- Don't use lambdas to return a function, use directly the function instead.
- Remove a hack for "Chrome 67 and earlier" since it was released in 2018.
2024-03-13 18:26:27 -07:00
Frédéric Guillot 8429c6b0ab Refactor JSON Feed parser to use an adapter 2024-03-12 22:37:14 -07:00
Frédéric Guillot 6bc4b35e38 Refactor RDF parser to use an adapter
Avoid tight coupling between `model.Feed` and the original XML RDF feed.
2024-03-12 20:54:05 -07:00
mcnesium ee3486af66 align min-width with the other min-width values 2024-03-12 18:58:30 -07:00
jvoisin 45d486b919 When detecting the format, detect its version as well
There is no need to detect the format and then the version when both can be
done at the same time.

Add a benchmark as well, on large and small atom and rss files.
2024-03-12 18:56:56 -07:00
dependabot[bot] 688b73b7ae Bump github.com/tdewolff/minify/v2 from 2.20.18 to 2.20.19
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.18 to 2.20.19.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.18...v2.20.19)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-12 17:38:27 -07:00
Frédéric Guillot 6d97f8b458 Parse podcast categories 2024-03-11 22:30:27 -07:00
Frédéric Guillot f8e50947f2 Move iTunes and GooglePlay XML definitions to their own packages 2024-03-11 22:09:31 -07:00
Frédéric Guillot 9a637ce95e Refactor RSS parser to use default namespace
This change avoid some limitations of the Go XML parser regarding XML namespaces
2024-03-11 21:07:13 -07:00
Frédéric Guillot d3a85b049b jsminifier: set JavaScript version 2024-03-11 19:02:52 -07:00
jvoisin 5bcb37901c Use crypto.GenerateRandomBytes instead of doing it by hand
This makes the code a bit shorter, and properly handle
cryptographic error conditions.
2024-03-11 16:31:43 -07:00
jvoisin 9c8a7dfffe Make use of HashFromBytes everywhere
It feels a bit silly to have a function and to not make use of it.
2024-03-11 15:22:22 -07:00
jvoisin 74e4032ffc Small refactor of app.js
- replace a lot of `let` with `const`
- inline some `querySelectorAll` calls
- reduce the scope of some variables
- use some ternaries where it makes sense
- inline one-line functions
2024-03-11 15:18:57 -07:00
jvoisin fd1fee852c Simplify DomHelper.getVisibleElements
Use a `filter` instead of a loop with an index.
2024-03-11 15:03:00 -07:00
Frédéric Guillot c51a3270da GitHub Actions: Add basic ESLinter checks 2024-03-10 20:57:27 -07:00
Frédéric Guillot 45fa641d26 Fix JavaScript linter path in GitHub Actions 2024-03-10 20:37:18 -07:00
jvoisin fd8f25916b First steps towards trusted-types support
Refactor away some trival usages of `.innerHTML`. Unfortunately, there is no way to
enabled trusted-types in report-only mode via `<meta>` tags, see
https://github.com/w3c/webappsec-csp/issues/277
2024-03-10 20:14:30 -07:00
jvoisin 826e4d654f Replace DomHelper.findParent with .closest
See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
2024-03-10 20:06:54 -07:00
jvoisin d9d17f0d69 Use a `Set` instead of an array in a KeyboardHandler's member
The variable `triggers` is only used to check if in contains a particular
value. Given that the number of keyboard shortcuts is starting to be
significant, let's future-proof the performances and use a `Set` instead of an
`Array` instead.
2024-03-10 19:41:13 -07:00
Frédéric Guillot eaaeb68474 Fix conditions to publish packages in GitHub workflows 2024-03-10 12:25:13 -07:00
190 changed files with 13027 additions and 6456 deletions

View File

@ -1,7 +1,7 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go
image: mcr.microsoft.com/devcontainers/go:1.22
volumes:
- ..:/workspace:cached
command: sleep infinity
@ -24,7 +24,7 @@ services:
ports:
- 5432:5432
apprise:
image: caronc/apprise:latest
image: caronc/apprise:1.0
restart: unless-stopped
hostname: apprise
volumes:

View File

@ -1,4 +1,7 @@
Do you follow the guidelines?
- [ ] I have tested my changes
- [ ] There is no breaking changes
- [ ] I really tested my changes and there is no regression
- [ ] Ideally, my commit messages use the same convention as the Go project: https://go.dev/doc/contribute#commit_messages
- [ ] I read this document: https://miniflux.app/faq.html#pull-request

View File

@ -12,7 +12,8 @@ jobs:
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
check-latest: true
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries

View File

@ -31,7 +31,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@ -56,7 +56,7 @@ jobs:
if-no-files-found: error
retention-days: 3
publish-packages:
if: github.event.push
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -8,35 +8,8 @@ on:
pull_request:
branches: [ main ]
jobs:
test-docker-images:
if: github.event.pull_request
name: Test Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Alpine image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
- name: Test Alpine Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
- name: Build Distroless image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
- name: Test Distroless Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
publish-docker-images:
if: ${{ ! github.event.pull_request }}
name: Publish Images
docker-images:
name: Docker Images
permissions:
packages: write
runs-on: ubuntu-latest
@ -46,33 +19,33 @@ jobs:
with:
fetch-depth: 0
- name: Generate Alpine Docker tag
id: docker_alpine_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
fi
echo ::set-output name=tags::${TAGS}
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
- name: Generate Distroless Docker tag
id: docker_distroless_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev-distroless
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
fi
echo ::set-output name=tags::${TAGS}
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
flavor: |
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -81,12 +54,14 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@ -94,6 +69,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: quay.io
@ -106,8 +82,8 @@ jobs:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v5
@ -115,5 +91,5 @@ jobs:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}

View File

@ -13,25 +13,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install jshint
- name: Install linters
run: |
sudo npm install -g jshint@2.13.3
sudo npm install -g jshint@2.13.6 eslint@8.57.0
- name: Run jshint
run: jshint ui/static/js/*.js
run: jshint internal/ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci:
name: Golang Linter
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v4
- 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
- uses: dominikh/staticcheck-action@v1.3.0
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2023.1.7"
install-go: false

View File

@ -38,7 +38,7 @@ jobs:
if-no-files-found: error
retention-days: 3
publish-package:
if: github.event.push
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -15,7 +15,7 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.22"]
go-version: ["1.22.x"]
steps:
- name: Set up Go
uses: actions/setup-go@v5
@ -43,7 +43,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.22.x"
- name: Checkout
uses: actions/checkout@v4
- name: Install Postgres client

106
ChangeLog
View File

@ -1,3 +1,109 @@
Version 2.1.3 (April 27, 2024)
------------------------------
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
* `finder`: find feeds from YouTube playlist
* `http/response`: add brotli compression support
* `integration/matrix`: fix function name in comment
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
* `reader/fetcher`: add brotli content encoding support
* `reader/processor`: minimize feed entries HTML content
* `reader/rewrite`: add a rule for `oglaf.com`
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
* `ui`: add seek and speed controls to media player
* `ui`: add tag entries page
* `ui`: fix JavaScript error when clicking on unread counter
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
Version 2.1.2 (March 30, 2024)
------------------------------
* `api`: rewrite API integration tests without build tags
* `ci`: add basic ESLinter checks
* `ci`: enable go-critic linter and fix various issues detected
* `ci`: fix JavaScript linter path in GitHub Actions
* `cli`: avoid misleading error message when creating an admin user automatically
* `config`: add `FILTER_ENTRY_MAX_AGE_DAYS` option
* `config`: bump the number of simultaneous workers
* `config`: rename `PROXY_*` options to `MEDIA_PROXY_*`
* `config`: use `crypto.GenerateRandomBytes` instead of doing it by hand
* `http/request`: refactor conditions to be more idiomatic
* `http/response`: remove legacy `X-XSS-Protection` header
* `integration/rssbrige`: fix rssbrige import
* `integration/shaarli`: factorize the header+payload concatenation as data
* `integration/shaarli`: no need to base64-encode then remove the padding when we can simply encode without padding
* `integration/shaarli`: the JWT token was declared as using HS256 as algorithm, but was using HS512
* `integration/webhook`: add category title to request body
* `locale`: update Turkish translations
* `man page`: sort config options in alphabetical order
* `mediaproxy`: reduce the internal indentation of `ProxifiedUrl` by inverting some conditions
* `mediaproxy`: simplify and refactor the package
* `model`: replace` Optional{Int,Int64,Float64}` with a generic function `OptionalNumber()`
* `model`: use struct embedding for `FeedCreationRequestFromSubscriptionDiscovery` to reduce code duplication
* `reader/atom`: avoid debug message when the date is empty
* `reader/atom`: change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}` to reduce the number of comparisons and improve readability
* `reader/atom`: Move the population of the feed's entries into a new function, to make BuildFeed easier to understand/separate concerns/implementation details
* `reader/atom`: refactor Atom parser to use an adapter
* `reader/atom`: use `sort+compact` instead of `compact+sort` to remove duplicates
* `reader/atom`: when detecting the format, detect its version as well
* `reader/encoding`: inline a one-liner function
* `reader/handler`: fix force refresh feature
* `reader/json`: refactor JSON Feed parser to use an adapter
* `reader/media`: remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed an empty string
* `reader/media`: simplify switch-case by moving a common condition above it
* `reader/processor`: compile block/keep regex only once per feed
* `reader/rdf`: refactor RDF parser to use an adapter
* `reader/rewrite`: inline some one-line functions
* `reader/rewrite`: simplify `removeClickbait`
* `reader/rewrite`: transform a free-standing function into a method
* `reader/rewrite`: use a proper constant instead of a magic number in `applyFuncOnTextContent`
* `reader/rss`: add support for `<media:category>` element
* `reader/rss`: don't add empty tags to RSS items
* `reader/rss`: refactor RSS parser to use a default namespace to avoid some limitations of the Go XML parser
* `reader/rss`: refactor RSS Parser to use an adapter
* `reader/rss`: remove some duplicated code in RSS parser
* `reader`: ensure that enclosure URLs are always absolute
* `reader`: move iTunes and GooglePlay XML definitions to their own packages
* `reader`: parse podcast categories
* `reader`: remove trailing space in `SiteURL` and `FeedURL`
* `storage`: do not store empty tags
* `storage`: simplify `removeDuplicates()` to use a `sort`+`compact` construct instead of doing it by hand with a hashmap
* `storage`: Use plain strings concatenation instead of building an array and then joining it
* `timezone`: make sure the tests pass when the timezone database is not installed on the host
* `ui/css`: align `min-width` with the other `min-width` values
* `ui/css`: fix regression: "Add to Home Screen" button is unreadable
* `ui/js`: don't use lambdas to return a function, use directly the function instead
* `ui/js`: enable trusted-types
* `ui/js`: fix download button loading label
* `ui/js`: fix JavaScript error on the login page when the user not authenticated
* `ui/js`: inline one-line functions
* `ui/js`: inline some `querySelectorAll` calls
* `ui/js`: reduce the scope of some variables
* `ui/js`: remove a hack for "Chrome 67 and earlier" since it was released in 2018
* `ui/js`: replace `DomHelper.findParent` with `.closest`
* `ui/js`: replace `let` with `const`
* `ui/js`: simplify `DomHelper.getVisibleElements` by using a `filter` instead of a loop with an index
* `ui/js`: use a `Set` instead of an array in a `KeyboardHandler`'s member
* `ui/js`: use some ternaries where it makes sense
* `ui/static`: make use of `HashFromBytes` everywhere
* `ui/static`: set minifier ECMAScript version
* `ui`: add keyboard shortcuts for scrolling to top/bottom of the item list
* `ui`: add media player control playback speed
* `ui`: remove unused variables and improve JSON decoding in `saveEnclosureProgression()`
* `validator`: display an error message on edit feed page when the feed URL is not unique
* Bump `github.com/coreos/go-oidc/v3` from `3.9.0` to `3.10.0`
* Bump `github.com/go-webauthn/webauthn` from `0.10.1` to `0.10.2`
* Bump `github.com/tdewolff/minify/v2` from `2.20.18` to `2.20.19`
* Bump `google.golang.org/protobuf` from `1.32.0` to `1.33.0`
Version 2.1.1 (March 10, 2024)
-----------------------------

View File

@ -101,7 +101,7 @@ windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
run:
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
@ -128,7 +128,11 @@ integration-test:
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`

View File

@ -18,16 +18,44 @@ type Client struct {
}
// New returns a new Miniflux client.
// Deprecated: use NewClient instead.
func New(endpoint string, credentials ...string) *Client {
// Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
return NewClient(endpoint, credentials...)
}
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
// trim to https://miniflux.app
if len(credentials) == 2 {
switch len(credentials) {
case 2:
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
case 1:
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
default:
return &Client{request: &request{endpoint: endpoint}}
}
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
body, err := c.request.Get("/healthcheck")
if err != nil {
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
}
defer body.Close()
responseBodyContent, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("miniflux: unable to read healthcheck response: %w", err)
}
if string(responseBodyContent) != "OK" {
return fmt.Errorf("miniflux: invalid healthcheck response: %q", responseBodyContent)
}
return nil
}
// Version returns the version of the Miniflux instance.
@ -528,6 +556,25 @@ func (c *Client) SaveEntry(entryID int64) error {
return err
}
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
if err != nil {
return "", err
}
defer body.Close()
var response struct {
Content string `json:"content"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", fmt.Errorf("miniflux: response error (%v)", err)
}
return response.Content, nil
}
// FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) {
body, err := c.request.Get("/v1/feeds/counters")

View File

@ -12,7 +12,7 @@ This code snippet fetch the list of users:
miniflux "miniflux.app/v2/client"
)
client := miniflux.New("https://api.example.org", "admin", "secret")
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
users, err := client.Users()
if err != nil {
fmt.Println(err)

View File

@ -41,6 +41,7 @@ type User struct {
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
}
func (u User) String() string {
@ -58,28 +59,29 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
}
// Users represents a list of users.
@ -290,3 +292,7 @@ type VersionResponse struct {
Arch string `json:"arch"`
OS string `json:"os"`
}
func SetOptionalField[T any](value T) *T {
return &value
}

View File

@ -26,6 +26,7 @@ var (
ErrForbidden = errors.New("miniflux: access forbidden")
ErrServerError = errors.New("miniflux: internal server error")
ErrNotFound = errors.New("miniflux: resource not found")
ErrBadRequest = errors.New("miniflux: bad request")
)
type errorResponse struct {
@ -124,10 +125,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
var resp errorResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("miniflux: bad request error (%v)", err)
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err)
}
return nil, fmt.Errorf("miniflux: bad request (%s)", resp.ErrorMessage)
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage)
}
if response.StatusCode > 400 {

39
go.mod
View File

@ -3,25 +3,27 @@ module miniflux.app/v2
// +heroku goVersion go1.22
require (
github.com/PuerkitoBio/goquery v1.9.1
github.com/PuerkitoBio/goquery v1.9.2
github.com/abadojack/whatlanggo v1.0.1
github.com/coreos/go-oidc/v3 v3.9.0
github.com/go-webauthn/webauthn v0.10.1
github.com/andybalholm/brotli v1.1.0
github.com/coreos/go-oidc/v3 v3.10.0
github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.19.0
github.com/tdewolff/minify/v2 v2.20.18
github.com/yuin/goldmark v1.7.0
golang.org/x/crypto v0.21.0
golang.org/x/net v0.22.0
golang.org/x/oauth2 v0.18.0
golang.org/x/term v0.18.0
github.com/prometheus/client_golang v1.19.1
github.com/tdewolff/minify/v2 v2.20.21
github.com/yuin/goldmark v1.7.1
golang.org/x/crypto v0.23.0
golang.org/x/net v0.25.0
golang.org/x/oauth2 v0.20.0
golang.org/x/term v0.20.0
golang.org/x/text v0.15.0
mvdan.cc/xurls/v2 v2.5.0
)
require (
github.com/go-webauthn/x v0.1.8 // indirect
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/go-webauthn/x v0.1.9 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
)
@ -29,20 +31,17 @@ require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/tdewolff/parse/v2 v2.7.12 // indirect
github.com/tdewolff/parse/v2 v2.7.13 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.32.0 // indirect
golang.org/x/sys v0.20.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)
go 1.22

101
go.sum
View File

@ -1,34 +1,29 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
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/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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
github.com/go-webauthn/x v0.1.8/go.mod h1:i8UNlGVt3oy6oAFcP4SZB1djZLx/4pbekCbWowjTaJg=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
@ -43,35 +38,32 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
github.com/tdewolff/minify/v2 v2.20.18/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
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.21 h1:8MCHcxXAVO8B7X+v07mwMWBIEtQo65e1JzBqDgZOQpU=
github.com/tdewolff/minify/v2 v2.20.21/go.mod h1:GYaLXFpIIwsX99apQHXfGdISUdlA98wmaoWxjT9C37k=
github.com/tdewolff/parse/v2 v2.7.13 h1:iSiwOUkCYLNfapHoqdLcqZVgvQ0jrsao8YYKP/UJYTI=
github.com/tdewolff/parse/v2 v2.7.13/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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=
@ -79,11 +71,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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.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=
@ -94,40 +85,28 @@ 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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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=

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,8 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/storage"
@ -36,14 +36,14 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
return
}
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
proxyOption := config.Opts.MediaProxyMode()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
@ -164,7 +164,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
}
for i := range entries {
entries[i].Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entries[i].Content)
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})

View File

@ -115,7 +115,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
return
}
if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil {
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}

View File

@ -16,7 +16,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")

View File

@ -23,7 +23,7 @@ const (
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create admin user"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
@ -191,7 +191,7 @@ func Parse() {
}
if flagCreateAdmin {
createAdmin(store)
createAdminUserFromInteractiveTerminal(store)
return
}
@ -211,9 +211,8 @@ func Parse() {
printErrorAndExit(err)
}
// Create admin user and start the daemon.
if config.Opts.CreateAdmin() {
createAdmin(store)
createAdminUserFromEnvironmentVariables(store)
}
if flagRefreshFeeds {

View File

@ -12,15 +12,20 @@ import (
"miniflux.app/v2/internal/validator"
)
func createAdmin(store *storage.Storage) {
userCreationRequest := &model.UserCreationRequest{
Username: config.Opts.AdminUsername(),
Password: config.Opts.AdminPassword(),
IsAdmin: true,
}
func createAdminUserFromEnvironmentVariables(store *storage.Storage) {
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
}
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
username, password := askCredentials()
createAdminUser(store, username, password)
}
func createAdminUser(store *storage.Storage, username, password string) {
userCreationRequest := &model.UserCreationRequest{
Username: username,
Password: password,
IsAdmin: true,
}
if store.UserExists(userCreationRequest.Username) {
@ -34,7 +39,12 @@ func createAdmin(store *storage.Storage) {
printErrorAndExit(validationErr.Error())
}
if _, err := store.CreateUser(userCreationRequest); err != nil {
if user, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err)
} else {
slog.Info("Created new admin user",
slog.String("username", user.Username),
slog.Int64("user_id", user.ID),
)
}
}

View File

@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"bytes"
"os"
"testing"
)
@ -1442,9 +1443,9 @@ func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
}
}
func TestProxyOption(t *testing.T) {
func TestMediaProxyMode(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("MEDIA_PROXY_MODE", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1453,14 +1454,14 @@ func TestProxyOption(t *testing.T) {
}
expected := "all"
result := opts.ProxyOption()
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
}
}
func TestDefaultProxyOptionValue(t *testing.T) {
func TestDefaultMediaProxyModeValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@ -1469,17 +1470,17 @@ func TestDefaultProxyOptionValue(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyOption
result := opts.ProxyOption()
expected := defaultMediaProxyMode
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypes(t *testing.T) {
func TestMediaProxyResourceTypes(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1489,25 +1490,25 @@ func TestProxyMediaTypes(t *testing.T) {
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio, image")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio, image")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1516,23 +1517,119 @@ func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
func TestDefaultMediaProxyResourceTypes(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestMediaProxyHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_HTTP_CLIENT_TIMEOUT", "24")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultMediaProxyHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultMediaProxyHTTPClientTimeout
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestMediaProxyCustomURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
}
}
func TestMediaProxyPrivateKey(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected MEDIA_PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
@ -1543,30 +1640,31 @@ func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
}
expected := []string{"image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
expectedProxyOption := "all"
result := opts.ProxyOption()
result := opts.MediaProxyMode()
if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
}
}
func TestDefaultProxyMediaTypes(t *testing.T) {
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1574,25 +1672,73 @@ func TestDefaultProxyMediaTypes(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
}
}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyHTTPClientTimeout(t *testing.T) {
func TestProxyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "all"
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
@ -1601,29 +1747,26 @@ func TestProxyHTTPClientTimeout(t *testing.T) {
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.ProxyHTTPClientTimeout()
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultProxyHTTPClientTimeoutValue(t *testing.T) {
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyHTTPClientTimeout
result := opts.ProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
@ -1878,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
}
}
func TestFetchNebulaWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchNebulaWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")

View File

@ -4,12 +4,12 @@
package config // import "miniflux.app/v2/internal/config"
import (
"crypto/rand"
"fmt"
"sort"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
@ -27,7 +27,7 @@ const (
defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost"
defaultBasePath = ""
defaultWorkerPoolSize = 5
defaultWorkerPoolSize = 16
defaultPollingFrequency = 60
defaultForceRefreshInterval = 30
defaultBatchSize = 100
@ -51,10 +51,12 @@ const (
defaultCleanupArchiveUnreadDays = 180
defaultCleanupArchiveBatchSize = 10000
defaultCleanupRemoveSessionsDays = 30
defaultProxyHTTPClientTimeout = 120
defaultProxyOption = "http-only"
defaultProxyMediaTypes = "image"
defaultProxyUrl = ""
defaultMediaProxyHTTPClientTimeout = 120
defaultMediaProxyMode = "http-only"
defaultMediaResourceTypes = "image"
defaultMediaProxyURL = ""
defaultFilterEntryMaxAgeDays = 0
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
@ -135,12 +137,14 @@ type Options struct {
createAdmin bool
adminUsername string
adminPassword string
proxyHTTPClientTimeout int
proxyOption string
proxyMediaTypes []string
proxyUrl string
mediaProxyHTTPClientTimeout int
mediaProxyMode string
mediaProxyResourceTypes []string
mediaProxyCustomURL string
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int
youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool
oauth2ClientID string
@ -165,15 +169,12 @@ type Options struct {
metricsPassword string
watchdog bool
invidiousInstance string
proxyPrivateKey []byte
mediaProxyPrivateKey []byte
webAuthn bool
}
// NewOptions returns Options with default values.
func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{
HTTPS: defaultHTTPS,
logFile: defaultLogFile,
@ -212,10 +213,12 @@ func NewOptions() *Options {
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
proxyOption: defaultProxyOption,
proxyMediaTypes: []string{defaultProxyMediaTypes},
proxyUrl: defaultProxyUrl,
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
mediaProxyMode: defaultMediaProxyMode,
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
mediaProxyCustomURL: defaultMediaProxyURL,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
@ -242,7 +245,7 @@ func NewOptions() *Options {
metricsPassword: defaultMetricsPassword,
watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance,
proxyPrivateKey: randomKey,
mediaProxyPrivateKey: crypto.GenerateRandomBytes(16),
webAuthn: defaultWebAuthn,
}
}
@ -486,30 +489,41 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
return o.fetchNebulaWatchTime
}
// FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool {
return o.fetchOdyseeWatchTime
}
// ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) ProxyOption() string {
return o.proxyOption
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) MediaProxyMode() string {
return o.mediaProxyMode
}
// ProxyMediaTypes returns a slice of media types to proxy.
func (o *Options) ProxyMediaTypes() []string {
return o.proxyMediaTypes
// MediaProxyResourceTypes returns a slice of resource types to proxy.
func (o *Options) MediaProxyResourceTypes() []string {
return o.mediaProxyResourceTypes
}
// ProxyUrl returns a string of a URL to use to proxy image requests
func (o *Options) ProxyUrl() string {
return o.proxyUrl
// MediaCustomProxyURL returns the custom proxy URL for medias.
func (o *Options) MediaCustomProxyURL() string {
return o.mediaProxyCustomURL
}
// ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) ProxyHTTPClientTimeout() int {
return o.proxyHTTPClientTimeout
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) MediaProxyHTTPClientTimeout() int {
return o.mediaProxyHTTPClientTimeout
}
// MediaProxyPrivateKey returns the private key used by the media proxy.
func (o *Options) MediaProxyPrivateKey() []byte {
return o.mediaProxyPrivateKey
}
// HasHTTPService returns true if the HTTP service is enabled.
@ -605,16 +619,16 @@ func (o *Options) InvidiousInstance() string {
return o.invidiousInstance
}
// ProxyPrivateKey returns the private key used by the media proxy
func (o *Options) ProxyPrivateKey() []byte {
return o.proxyPrivateKey
}
// WebAuthn returns true if WebAuthn logins are supported
func (o *Options) WebAuthn() bool {
return o.webAuthn
}
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
func (o *Options) FilterEntryMaxAgeDays() int {
return o.filterEntryMaxAgeDays
}
// SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option {
var keyValues = map[string]interface{}{
@ -640,7 +654,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DISABLE_HSTS": !o.hsts,
"DISABLE_HTTP_SERVICE": !o.httpService,
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
@ -674,11 +690,11 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
"POLLING_SCHEDULER": o.pollingScheduler,
"PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
"PROXY_MEDIA_TYPES": o.proxyMediaTypes,
"PROXY_OPTION": o.proxyOption,
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
"PROXY_URL": o.proxyUrl,
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes,
"MEDIA_PROXY_MODE": o.mediaProxyMode,
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret),
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
"ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,

View File

@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@ -87,6 +88,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.logFormat = parsedValue
}
case "DEBUG":
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
@ -112,6 +114,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "DATABASE_CONNECTION_LIFETIME":
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
case "FILTER_ENTRY_MAX_AGE_DAYS":
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS":
@ -158,20 +162,41 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
// kept for compatibility purpose
case "PROXY_IMAGES":
p.opts.proxyOption = parseString(value, defaultProxyOption)
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "PROXY_OPTION":
p.opts.proxyOption = parseString(value, defaultProxyOption)
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_MEDIA_TYPES":
p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
// kept for compatibility purpose
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "PROXY_IMAGE_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_PRIVATE_KEY":
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
@ -234,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_NEBULA_WATCH_TIME":
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":
@ -244,10 +271,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
}

View File

@ -17,8 +17,7 @@ import (
// HashFromBytes returns a SHA-256 checksum of the input.
func HashFromBytes(value []byte) string {
sum := sha256.Sum256(value)
return fmt.Sprintf("%x", sum)
return fmt.Sprintf("%x", sha256.Sum256(value))
}
// Hash returns a SHA-256 checksum of a string.

View File

@ -871,4 +871,36 @@ 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 media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
}

View File

@ -13,8 +13,8 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/storage"
"github.com/gorilla/mux"
@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content),
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content),
URL: entry.URL,
IsSaved: isSaved,
IsRead: isRead,

View File

@ -18,8 +18,8 @@ import (
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription"
@ -265,9 +265,10 @@ func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
}
func getStream(streamID string, userID int64) (Stream, error) {
if strings.HasPrefix(streamID, FeedPrefix) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix) {
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix)
switch id {
@ -288,15 +289,15 @@ func getStream(streamID string, userID int64) (Stream, error) {
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
} else if streamID == "" {
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
@ -382,7 +383,7 @@ func getItemIDs(r *http.Request) ([]int64, error) {
return itemIDs, nil
}
func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
err := r.ParseForm()
@ -736,11 +737,12 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
}
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
if category.ID == "" {
switch {
case category.ID == "":
return store.FirstCategory(userID)
} else if store.CategoryTitleExists(userID, category.ID) {
case store.CategoryTitleExists(userID, category.ID):
return store.CategoryByTitle(userID, category.ID)
} else {
default:
catRequest := model.CategoryRequest{
Title: category.ID,
}
@ -764,7 +766,7 @@ func subscribe(newFeed Stream, category Stream, title string, store *storage.Sto
}
created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
if err != nil {
if localizedError != nil {
return nil, localizedError.Error()
}
@ -908,7 +910,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1001,14 +1003,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories = append(categories, userStarred)
}
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
proxyOption := config.Opts.MediaProxyMode()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
@ -1170,7 +1172,7 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1205,7 +1207,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1224,7 +1226,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: "", //TODO Icons are only base64 encode in DB yet
IconURL: "", // TODO: Icons are base64 encoded in the DB.
})
}
json.OK(w, r, result)
@ -1251,7 +1253,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1276,7 +1278,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1477,8 +1479,7 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
}

View File

@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
// Cookie duration in days.
cookieDuration = 30
)
// New creates a new cookie.
@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
SameSite: http.SameSiteLaxMode,
}
}

View File

@ -37,14 +37,10 @@ const (
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
value, valid := v.(model.WebAuthnSession)
if !valid {
return nil
if value, valid := v.(model.WebAuthnSession); valid {
return &value
}
return &value
}
return nil
}
@ -151,39 +147,27 @@ func ClientIP(r *http.Request) string {
func getContextStringValue(r *http.Request, key ContextKey) string {
if v := r.Context().Value(key); v != nil {
value, valid := v.(string)
if !valid {
return ""
if value, valid := v.(string); valid {
return value
}
return value
}
return ""
}
func getContextBoolValue(r *http.Request, key ContextKey) bool {
if v := r.Context().Value(key); v != nil {
value, valid := v.(bool)
if !valid {
return false
if value, valid := v.(bool); valid {
return value
}
return value
}
return false
}
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
if v := r.Context().Value(key); v != nil {
value, valid := v.(int64)
if !valid {
return 0
if value, valid := v.(int64); valid {
return value
}
return value
}
return 0
}

View File

@ -12,6 +12,8 @@ import (
"net/http"
"strings"
"time"
"github.com/andybalholm/brotli"
)
const compressionThreshold = 1024
@ -96,7 +98,6 @@ func (b *Builder) Write() {
}
func (b *Builder) writeHeaders() {
b.headers["X-XSS-Protection"] = "1; mode=block"
b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer"
@ -111,8 +112,15 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()

View File

@ -28,7 +28,6 @@ func TestResponseHasCommonHeaders(t *testing.T) {
resp := w.Result()
headers := map[string]string{
"X-XSS-Protection": "1; mode=block",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
@ -229,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
@ -246,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "br"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "gzip"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {

View File

@ -19,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
@ -376,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.

View File

@ -10,7 +10,7 @@ import (
"miniflux.app/v2/internal/model"
)
// PushEntry pushes entries to matrix chat using integration settings provided
// PushEntries pushes entries to matrix chat using integration settings provided
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
client := NewClient(matrixBaseURL)
discovery, err := client.DiscoverEndpoints()

View File

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
collectionID string
tags []string
}
func NewClient(token, collectionID, tags string) *Client {
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
}
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
if c.token == "" {
return fmt.Errorf("raindrop: missing token")
}
var request *http.Request
requestBodyJson, err := json.Marshal(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
})
if err != nil {
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("raindrop: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
}
return nil
}
type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection,omitempty"`
Tags []string `json:"tags"`
}
type collection struct {
Id string `json:"$id"`
}

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package rssbridge // import "miniflux.app/integration/rssbridge"
package rssbridge // import "miniflux.app/v2/internal/integration/rssbridge"
import (
"encoding/json"

View File

@ -11,7 +11,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
@ -74,14 +73,15 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
}
func (c *Client) generateBearerToken() string {
header := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret))
mac.Write([]byte(header + "." + payload))
signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
mac.Write([]byte(data))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return header + "." + payload + "." + signature
return data + "." + signature
}
type addLinkRequest struct {

View File

@ -57,6 +57,7 @@ func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {
ID: entry.Feed.ID,
UserID: entry.Feed.UserID,
CategoryID: entry.Feed.Category.ID,
Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},
FeedURL: entry.Feed.FeedURL,
SiteURL: entry.Feed.SiteURL,
Title: entry.Feed.Title,
@ -94,13 +95,13 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
Tags: entry.Tags,
})
}
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
EventType: NewEntriesEventType,
Feed: &WebhookFeed{
ID: feed.ID,
UserID: feed.UserID,
CategoryID: feed.Category.ID,
Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},
FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL,
Title: feed.Title,
@ -145,13 +146,19 @@ func (c *Client) makeRequest(eventType string, payload any) error {
}
type WebhookFeed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"`
Category *WebhookCategory `json:"category,omitempty"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
}
type WebhookCategory struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type WebhookEntry struct {

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
"page.keyboard_shortcuts.go_to_bottom_item": "Gehen Sie zum untersten Element",
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
"page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen",
"page.keyboard_shortcuts.open_original": "Original-Artikel öffnen",
"page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "URL der Webseite",
"form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.description": "Beschreibung",
"form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"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": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
@ -505,7 +513,7 @@
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.tls_error": "TLS-Fehler: %v. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
"error.tls_error": "TLS-Fehler: %q. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
"error.http_client_error": "HTTP-Client-Fehler: %v.",
@ -524,5 +532,16 @@
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v."
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
"page.keyboard_shortcuts.go_to_bottom_item": "Μετάβαση στο κάτω στοιχείο",
"page.keyboard_shortcuts.go_to_top_item": "Μετάβαση στο επάνω στοιχείο",
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
@ -316,6 +319,7 @@
"form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας 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": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"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"
}

View File

@ -176,6 +176,8 @@
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
"page.keyboard_shortcuts.go_to_top_item": "Go to top item",
"page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item",
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
"page.keyboard_shortcuts.open_item": "Open selected item",
@ -215,7 +217,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.settings.webauthn.delete" : [
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
],
@ -256,6 +258,7 @@
"alert.no_bookmark": "There are no starred entries.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.",
"alert.no_tag_entry": "There are no entries matching this tag.",
"alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed": "You dont have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Description",
"form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"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": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"error.settings_media_playback_rate_range": "Playback speed is out of range",
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir al elemento superior",
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
"page.keyboard_shortcuts.open_original": "Abrir el enlace original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual",
@ -256,6 +258,7 @@
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.description": "Descripción",
"form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de 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": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
"page.keyboard_shortcuts.go_to_bottom_item": "Siirry alimpaan kohtaan",
"page.keyboard_shortcuts.go_to_top_item": "Siirry alkuun",
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
"page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki",
"page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
@ -316,6 +319,7 @@
"form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.description": "Kuvaus",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"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": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
"page.keyboard_shortcuts.go_to_bottom_item": "Aller à l'élément du bas",
"page.keyboard_shortcuts.go_to_top_item": "Aller à l'élément supérieur",
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
"page.keyboard_shortcuts.open_original": "Ouvrir le lien original",
"page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours",
@ -215,7 +217,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.settings.webauthn.delete" : [
"page.settings.webauthn.delete": [
"Supprimer %d clé daccès",
"Supprimer %d clés daccès"
],
@ -256,6 +258,7 @@
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
"form.feed.label.description": "Description",
"form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle 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": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
@ -505,7 +513,7 @@
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
"error.tls_error": "Erreur TLS : %v. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.tls_error": "Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
"error.network_timeout": "Ce site web est trop lent à répondre : %v.",
"error.http_client_error": "Erreur du client HTTP : %v.",
@ -524,5 +532,16 @@
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
"error.feed_not_found": "Impossible de trouver ce flux.",
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v."
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
"enclosure_media_controls.seek" : "Avancer/Reculer :",
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
"enclosure_media_controls.speed" : "Vitesse :",
"enclosure_media_controls.speed.faster" : "Accélérer",
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
"enclosure_media_controls.speed.slower" : "Ralentir",
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
"enclosure_media_controls.speed.reset" : "Réinitialiser",
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.go_to_bottom_item": "निचले आइटम पर जाएँ",
"page.keyboard_shortcuts.go_to_top_item": "शीर्ष आइटम पर जाएँ",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
@ -256,6 +258,7 @@
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
@ -314,6 +317,7 @@
"form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.description": "विवरण",
"form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"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": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"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"
}

View File

@ -169,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
"page.keyboard_shortcuts.go_to_bottom_item": "Pergi ke item paling bawah",
"page.keyboard_shortcuts.go_to_top_item": "Pergi ke item teratas",
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
"page.keyboard_shortcuts.open_original": "Buka tautan asli",
"page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini",
@ -246,6 +248,7 @@
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
@ -304,6 +307,7 @@
"form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.description": "Deskripsi",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
@ -439,6 +443,10 @@
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang 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": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
@ -488,7 +496,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -507,5 +515,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
"page.keyboard_shortcuts.go_to_bottom_item": "Vai all'elemento in fondo",
"page.keyboard_shortcuts.go_to_top_item": "Vai all'elemento principale",
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
"page.keyboard_shortcuts.open_original": "Apri la pagina web originale",
"page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
"form.feed.label.description": "Descrizione",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza 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": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
"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"
}

View File

@ -169,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "フィード",
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
"page.keyboard_shortcuts.go_to_bottom_item": "一番下の項目に移動",
"page.keyboard_shortcuts.go_to_top_item": "先頭の項目に移動",
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
@ -246,6 +248,7 @@
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
@ -304,6 +307,7 @@
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
"form.feed.label.description": "説明",
"form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名",
@ -439,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"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": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key",
@ -488,7 +496,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -507,5 +515,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"error.settings_media_playback_rate_range": "再生速度が範囲外",
"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"
}

View File

@ -179,6 +179,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
"page.keyboard_shortcuts.open_original": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Beschrijving",
"form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"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": "Opslaan naar Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"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"
}

View File

@ -187,6 +187,8 @@
"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",
@ -266,6 +268,7 @@
"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.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
@ -324,6 +327,7 @@
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "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",
@ -459,6 +463,10 @@
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "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",
@ -522,7 +530,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -541,5 +549,16 @@
"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."
"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"
}

View File

@ -178,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir para o item inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir para o item superior",
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
"page.keyboard_shortcuts.open_original": "Abrir o conteúdo original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual",
@ -256,6 +258,7 @@
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
@ -314,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte",
"form.feed.label.description": "Descrição",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte",
@ -449,6 +453,10 @@
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala 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": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
@ -505,7 +513,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -524,5 +532,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"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"
}

View File

@ -187,6 +187,8 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти к нижнему элементу",
"page.keyboard_shortcuts.go_to_top_item": "Перейти к верхнему элементу",
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
@ -266,6 +268,7 @@
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.",
@ -324,6 +327,7 @@
"form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.description": "Описание",
"form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки",
@ -459,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты 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": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck",
@ -522,7 +530,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -541,5 +549,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"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"
}

File diff suppressed because it is too large Load Diff

View File

@ -187,6 +187,8 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти до нижнього пункту",
"page.keyboard_shortcuts.go_to_top_item": "Перейти до верхнього пункту",
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
@ -266,6 +268,7 @@
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.",
@ -324,6 +327,7 @@
"form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.description": "Опис",
"form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
@ -459,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"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": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck",
@ -522,7 +530,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -541,5 +549,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"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"
}

View File

@ -169,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
"page.keyboard_shortcuts.go_to_next_page": "下一页",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "打开选定的文章",
"page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
@ -246,6 +248,7 @@
"alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
"alert.no_tag_entry": "没有与此标签匹配的条目。",
"alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史",
@ -304,6 +307,7 @@
"form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名",
@ -406,9 +410,9 @@
"form.integration.omnivore_activate": "保存文章到 Omnivore",
"form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_tags": "Espial 标签",
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
@ -439,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"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": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥",
@ -488,7 +496,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -507,5 +515,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出范围",
"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"
}

View File

@ -169,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
"page.keyboard_shortcuts.open_original": "開啟原始連結",
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
@ -246,6 +248,7 @@
"alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
"alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史",
@ -304,6 +307,7 @@
"form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱",
@ -439,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"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": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰",
@ -488,7 +496,7 @@
"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: %v. You could disable TLS verification in the feed settings if you would like.",
"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.",
@ -507,5 +515,16 @@
"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."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出範圍",
"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"
}

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"net/http"
@ -29,11 +29,11 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -53,11 +53,11 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -76,11 +76,11 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -99,11 +99,11 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -124,11 +124,11 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -149,11 +149,87 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("HTTPS", "1")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="https://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "audio")
os.Setenv("PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<audio src="https://website/folder/audio.mp3"></audio>`
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -174,11 +250,61 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "http://:8080example.com")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -198,11 +324,11 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -222,11 +348,11 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -248,7 +374,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -273,7 +399,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -298,7 +424,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -323,7 +449,7 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -347,7 +473,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test">`
expected := `<img src="data:image/gif;base64,test"/>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -371,7 +497,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
input := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -396,7 +522,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -421,7 +547,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"strings"
@ -16,31 +16,29 @@ import (
type urlProxyRewriter func(router *mux.Router, url string) string
// ProxyRewriter replaces media URLs with internal proxy URLs.
func ProxyRewriter(router *mux.Router, data string) string {
return genericProxyRewriter(router, ProxifyURL, data)
func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string) string {
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
}
// AbsoluteProxyRewriter do the same as ProxyRewriter except it uses absolute URLs.
func AbsoluteProxyRewriter(router *mux.Router, host, data string) string {
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, host, htmlDocument string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return AbsoluteProxifyURL(router, host, url)
return ProxifyAbsoluteURL(router, host, url)
}
return genericProxyRewriter(router, proxifyFunction, data)
return genericProxyRewriter(router, proxifyFunction, htmlDocument)
}
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
proxyOption := config.Opts.ProxyOption()
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "none" {
return data
return htmlDocument
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))
if err != nil {
return data
return htmlDocument
}
for _, mediaType := range config.Opts.ProxyMediaTypes() {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
switch mediaType {
case "image":
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
@ -91,7 +89,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
output, err := doc.Find("body").First().Html()
if err != nil {
return data
return htmlDocument
}
return output

View File

@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/url"
"path"
"miniflux.app/v2/internal/http/route"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
mac.Write([]byte(mediaURL))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
}
func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
scheme := "http"
if config.Opts.HTTPS {
scheme = "https"
}
return scheme + "://" + host + proxifiedUrl
}
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
if customProxyURL == "" {
return mediaURL
}
proxyUrl, err := url.Parse(customProxyURL)
if err != nil {
slog.Error("Incorrect custom media proxy URL",
slog.String("custom_proxy_url", customProxyURL),
slog.Any("error", err),
)
return mediaURL
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
return proxyUrl.String()
}

View File

@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import "strings"
// Enclosure represents an attachment.
type Enclosure struct {
@ -17,15 +16,8 @@ type Enclosure struct {
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e Enclosure) Html5MimeType() string {
if strings.HasPrefix(e.MimeType, "video") {
switch e.MimeType {
// Solution from this stackoverflow discussion:
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
case "video/m4v":
return "video/x-m4v"
}
if e.MimeType == "video/m4v" {
return "video/x-m4v"
}
return e.MimeType
}

View File

@ -28,6 +28,7 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
@ -159,25 +160,7 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
ETag string
LastModified string
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
FeedCreationRequest
}
// FeedModificationRequest represents the request to update a feed.
@ -185,6 +168,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
@ -219,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Title = *f.Title
}
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}

View File

@ -90,4 +90,8 @@ type Integration struct {
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
}

View File

@ -3,26 +3,20 @@
package model // import "miniflux.app/v2/internal/model"
// OptionalString populates an optional string field.
type Number interface {
int | int64 | float64
}
func OptionalNumber[T Number](value T) *T {
if value > 0 {
return &value
}
return nil
}
func OptionalString(value string) *string {
if value != "" {
return &value
}
return nil
}
// OptionalInt populates an optional int field.
func OptionalInt(value int) *int {
if value > 0 {
return &value
}
return nil
}
// OptionalInt64 populates an optional int64 field.
func OptionalInt64(value int64) *int64 {
if value > 0 {
return &value
}
return nil
}

View File

@ -35,6 +35,7 @@ type User struct {
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
}
// UserCreationRequest represents the request to create a user.
@ -48,28 +49,29 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
}
// Patch updates the User object with the modification request.
@ -161,6 +163,10 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.MarkReadOnView != nil {
user.MarkReadOnView = *u.MarkReadOnView
}
if u.MediaPlaybackRate != nil {
user.MediaPlaybackRate = *u.MediaPlaybackRate
}
}
// UseTimezone converts last login date to the given timezone.

View File

@ -1,69 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/url"
"path"
"miniflux.app/v2/internal/http/route"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
// ProxifyURL generates a relative URL for a proxified resource.
func ProxifyURL(router *mux.Router, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}
// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
if config.Opts.HTTPS {
return "https://" + host + path
} else {
return "http://" + host + path
}
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}

View File

@ -6,158 +6,114 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/base64"
"html"
"log/slog"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type atom03Feed struct {
ID string `xml:"id"`
Title atom03Text `xml:"title"`
Author atomPerson `xml:"author"`
Links atomLinks `xml:"link"`
Entries []atom03Entry `xml:"entry"`
type Atom03Feed struct {
Version string `xml:"version,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
ID string `xml:"http://purl.org/atom/ns# id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
// atom:feed elements MUST contain exactly one atom:title element.
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
// The "atom:author" element is a Person construct that indicates the default author of the feed.
// atom:feed elements MUST contain exactly one atom:author element,
// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.
// atom:feed elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"http://purl.org/atom/ns# author"`
// The "atom:entry" element's represents an individual entry that is contained by the feed.
// atom:feed elements MAY contain one or more atom:entry elements.
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
}
func (a *atom03Feed) Transform(baseURL string) *model.Feed {
var err error
type Atom03Entry struct {
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
ID string `xml:"id"`
feed := new(model.Feed)
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"title"`
feedURL := a.Links.firstLinkWithRelation("self")
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
if err != nil {
feed.FeedURL = feedURL
}
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC".
Modified string `xml:"modified"`
siteURL := a.Links.originalLink()
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
if err != nil {
feed.SiteURL = siteURL
}
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued.
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.
// The content of an atom:issued element MAY omit a time zone.
Issued string `xml:"issued"`
feed.Title = a.Title.String()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// The "atom:created" element is a Date construct that indicates the time that the entry was created.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
// The content of an atom:created element MUST have a time zone whose value SHOULD be "UTC".
// If atom:created is not present, its content MUST considered to be the same as that of atom:modified.
Created string `xml:"created"`
for _, entry := range a.Entries {
item := entry.Transform()
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
if err == nil {
item.URL = entryURL
}
// The "atom:link" element is a Link construct that conveys a URI associated with the entry.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"link"`
if item.Author == "" {
item.Author = a.Author.String()
}
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
Summary Atom03Content `xml:"summary"`
if item.Title == "" {
item.Title = sanitizer.TruncateHTML(item.Content, 100)
}
// The "atom:content" element is a Content construct that conveys the content of the entry.
// atom:entry elements MAY contain one or more atom:content elements.
Content Atom03Content `xml:"content"`
if item.Title == "" {
item.Title = item.URL
}
feed.Entries = append(feed.Entries, item)
}
return feed
// The "atom:author" element is a Person construct that indicates the default author of the entry.
// atom:entry elements MUST contain exactly one atom:author element,
// UNLESS the atom:feed element containing them contains an atom:author element itself.
// atom:entry elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"author"`
}
type atom03Entry struct {
ID string `xml:"id"`
Title atom03Text `xml:"title"`
Modified string `xml:"modified"`
Issued string `xml:"issued"`
Created string `xml:"created"`
Links atomLinks `xml:"link"`
Summary atom03Text `xml:"summary"`
Content atom03Text `xml:"content"`
Author atomPerson `xml:"author"`
}
type Atom03Content struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
// When present, this attribute's value MUST be a registered media type [RFC2045].
// If not present, its value MUST be considered to be "text/plain".
Type string `xml:"type,attr"`
func (a *atom03Entry) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = a.Links.originalLink()
entry.Date = a.entryDate()
entry.Author = a.Author.String()
entry.Hash = a.entryHash()
entry.Content = a.entryContent()
entry.Title = a.entryTitle()
return entry
}
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content.
// When present, this attribute's value MUST be listed below.
// If not present, its value MUST be considered to be "xml".
//
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).
//
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string.
// Processors MUST unescape the element's content before considering it as content of the indicated media type.
//
// "base64": A mode attribute with the value "base64" indicates that the element's content is base64-encoded [RFC2045].
// Processors MUST decode the element's content before considering it as content of the the indicated media type.
Mode string `xml:"mode,attr"`
func (a *atom03Entry) entryTitle() string {
return sanitizer.StripTags(a.Title.String())
}
func (a *atom03Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
return ""
}
func (a *atom03Entry) entryDate() time.Time {
dateText := ""
for _, value := range []string{a.Issued, a.Modified, a.Created} {
if value != "" {
dateText = value
break
}
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom03Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
type atom03Text struct {
Type string `xml:"type,attr"`
Mode string `xml:"mode,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
}
func (a *atom03Text) String() string {
func (a *Atom03Content) Content() string {
content := ""
switch {

View File

@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom03Adapter struct {
atomFeed *Atom03Feed
}
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
return &Atom03Adapter{atomFeed}
}
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Content()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Content()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Content()
}
// Populate the entry title.
entry.Title = atomEntry.Title.Content()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry author.
entry.Author = atomEntry.Author.PersonName()
if entry.Author == "" {
entry.Author = a.atomFeed.Author.PersonName()
}
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}

View File

@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -36,7 +36,7 @@ func TestParseAtom03(t *testing.T) {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
if feed.FeedURL != "http://diveintomark.org/" {
if feed.FeedURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
@ -74,6 +74,28 @@ func TestParseAtom03(t *testing.T) {
}
}
func TestParseAtom03WithoutSiteURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutFeedTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
@ -87,7 +109,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -110,7 +132,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -138,7 +160,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -166,7 +188,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -197,7 +219,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -228,7 +250,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -259,7 +281,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}

View File

@ -6,286 +6,200 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/xml"
"html"
"log/slog"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// The "atom:feed" element is the document (i.e., top-level) element of
// an Atom Feed Document, acting as a container for metadata and data
// associated with the feed. Its element children consist of metadata
// elements followed by zero or more atom:entry child elements.
//
// Specs:
// https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
ID string `xml:"id"`
Title atom10Text `xml:"title"`
Authors atomAuthors `xml:"author"`
Icon string `xml:"icon"`
Links atomLinks `xml:"link"`
Entries []atom10Entry `xml:"entry"`
type Atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:feed elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:feed elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
// feed.
//
// atom:feed elements MUST NOT contain more than one atom:icon element.
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
// The "atom:logo" element's content is an IRI reference [RFC3987] that
// identifies an image that provides visual identification for a feed.
//
// atom:feed elements MUST NOT contain more than one atom:logo element.
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
// atom:feed elements SHOULD contain one atom:link element with a rel
// attribute value of "self". This is the preferred URI for
// retrieving Atom Feed Documents representing this Atom feed.
//
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
}
func (a *atom10Feed) Transform(baseURL string) *model.Feed {
var err error
type Atom10Entry struct {
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:entry elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
feed := new(model.Feed)
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:entry elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
feedURL := a.Links.firstLinkWithRelation("self")
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
if err != nil {
feed.FeedURL = feedURL
}
// The "atom:published" element is a Date construct indicating an
// instant in time associated with an event early in the life cycle of
// the entry.
Published string `xml:"http://www.w3.org/2005/Atom published"`
siteURL := a.Links.originalLink()
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
if err != nil {
feed.SiteURL = siteURL
}
// The "atom:updated" element is a Date construct indicating the most
// recent instant in time when an entry or feed was modified in a way
// the publisher considers significant. Therefore, not all
// modifications necessarily result in a changed atom:updated value.
//
// atom:entry elements MUST contain exactly one atom:updated element.
Updated string `xml:"http://www.w3.org/2005/Atom updated"`
feed.Title = html.UnescapeString(a.Title.String())
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// atom:entry elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
feed.IconURL = strings.TrimSpace(a.Icon)
// atom:entry elements MUST contain an atom:summary element in either
// of the following cases:
// * the atom:entry contains an atom:content that has a "src"
// attribute (and is thus empty).
// * the atom:entry contains content that is encoded in Base64;
// i.e., the "type" attribute of atom:content is a MIME media type
// [MIMEREG], but is not an XML media type [RFC3023], does not
// begin with "text/", and does not end with "/xml" or "+xml".
//
// atom:entry elements MUST NOT contain more than one atom:summary
// element.
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
for _, entry := range a.Entries {
item := entry.Transform()
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
if err == nil {
item.URL = entryURL
}
// atom:entry elements MUST NOT contain more than one atom:content
// element.
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"`
if item.Author == "" {
item.Author = a.Authors.String()
}
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:entry elements MUST contain one or more atom:author elements
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
if item.Title == "" {
item.Title = sanitizer.TruncateHTML(item.Content, 100)
}
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:entry elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
if item.Title == "" {
item.Title = item.URL
}
feed.Entries = append(feed.Entries, item)
}
return feed
}
type atom10Entry struct {
ID string `xml:"id"`
Title atom10Text `xml:"title"`
Published string `xml:"published"`
Updated string `xml:"updated"`
Links atomLinks `xml:"link"`
Summary atom10Text `xml:"summary"`
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
Authors atomAuthors `xml:"author"`
Categories []atom10Category `xml:"category"`
media.Element
}
func (a *atom10Entry) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = a.Links.originalLink()
entry.Date = a.entryDate()
entry.Author = a.Authors.String()
entry.Hash = a.entryHash()
entry.Content = a.entryContent()
entry.Title = a.entryTitle()
entry.Enclosures = a.entryEnclosures()
entry.CommentsURL = a.entryCommentsURL()
entry.Tags = a.entryCategories()
return entry
}
func (a *atom10Entry) entryTitle() string {
return html.UnescapeString(a.Title.String())
}
func (a *atom10Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
mediaDescription := a.FirstMediaDescription()
if mediaDescription != "" {
return mediaDescription
}
return ""
}
// Note: The published date represents the original creation date for YouTube feeds.
// Example:
// <published>2019-01-26T08:02:28+00:00</published>
// <updated>2019-01-29T07:27:27+00:00</updated>
func (a *atom10Entry) entryDate() time.Time {
dateText := a.Published
if dateText == "" {
dateText = a.Updated
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom10Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
func (a *atom10Entry) entryEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool)
for _, mediaThumbnail := range a.AllMediaThumbnails() {
if _, found := duplicates[mediaThumbnail.URL]; !found {
duplicates[mediaThumbnail.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaThumbnail.URL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
for _, link := range a.Links {
if strings.EqualFold(link.Rel, "enclosure") {
if link.URL == "" {
continue
}
if _, found := duplicates[link.URL]; !found {
duplicates[link.URL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
}
}
}
for _, mediaContent := range a.AllMediaContents() {
if _, found := duplicates[mediaContent.URL]; !found {
duplicates[mediaContent.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaContent.URL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
for _, mediaPeerLink := range a.AllMediaPeerLinks() {
if _, found := duplicates[mediaPeerLink.URL]; !found {
duplicates[mediaPeerLink.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaPeerLink.URL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
return enclosures
}
func (r *atom10Entry) entryCategories() []string {
categoryList := make([]string, 0)
for _, atomCategory := range r.Categories {
if strings.TrimSpace(atomCategory.Label) != "" {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Label))
} else {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Term))
}
}
return categoryList
}
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
func (a *atom10Entry) entryCommentsURL() string {
commentsURL := a.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
return commentsURL
}
return ""
}
type atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
type atom10Category struct {
Term string `xml:"term,attr"`
Label string `xml:"label,attr"`
media.MediaItemElement
}
// A Text construct contains human-readable text, usually in small
// quantities. The content of Text constructs is Language-Sensitive.
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
func (a *atom10Text) String() string {
type Atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
func (a *Atom10Text) Body() string {
var content string
switch {
case a.Type == "", a.Type == "text", a.Type == "text/plain":
if strings.HasPrefix(strings.TrimSpace(a.InnerXML), `<![CDATA[`) {
content = html.EscapeString(a.CharData)
} else {
content = a.InnerXML
}
case a.Type == "xhtml":
var root = a.XHTMLRootElement
if root.XMLName.Local == "div" {
content = root.InnerXML
} else {
content = a.InnerXML
}
default:
if strings.EqualFold(a.Type, "xhtml") {
content = a.xhtmlContent()
} else {
content = a.CharData
}
return strings.TrimSpace(content)
}
type atomXHTMLRootElement struct {
func (a *Atom10Text) Title() string {
var content string
switch {
case strings.EqualFold(a.Type, "xhtml"):
content = a.xhtmlContent()
case strings.Contains(a.InnerXML, "<![CDATA["):
content = html.UnescapeString(a.CharData)
default:
content = a.CharData
}
content = sanitizer.StripTags(content)
return strings.TrimSpace(content)
}
func (a *Atom10Text) xhtmlContent() string {
if a.XHTMLRootElement.XMLName.Local == "div" {
return a.XHTMLRootElement.InnerXML
}
return a.InnerXML
}
type AtomXHTMLRootElement struct {
XMLName xml.Name `xml:"div"`
InnerXML string `xml:",innerxml"`
}

View File

@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"slices"
"sort"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom10Adapter struct {
atomFeed *Atom10Feed
}
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
return &Atom10Adapter{atomFeed}
}
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Body()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
feed.IconURL = absoluteIconURL
}
} else if a.atomFeed.Logo != "" {
if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
feed.IconURL = absoluteLogoURL
}
}
feed.Entries = a.populateEntries(feed.SiteURL)
return feed
}
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Body()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Body()
if entry.Content == "" {
entry.Content = atomEntry.FirstMediaDescription()
}
}
// Populate the entry title.
entry.Title = atomEntry.Title.Title()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
if entry.Title == "" {
entry.Title = entry.URL
}
}
// Populate the entry author.
authors := atomEntry.Authors.PersonNames()
if len(authors) == 0 {
authors = a.atomFeed.Authors.PersonNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
entry.Author = strings.Join(authors, ", ")
// Populate the entry date.
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
if value != "" {
if parsedDate, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = parsedDate
break
}
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate categories.
categories := atomEntry.Categories.CategoryNames()
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
}
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
// Populate the commentsURL if defined.
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
entry.CommentsURL = commentsURL
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
// Populate the entry enclosures.
uniqueEnclosuresMap := make(map[string]bool)
for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
if mediaURL == "" {
continue
}
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
}
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, link.Href)
if err != nil {
slog.Debug("Unable to resolve absolute URL for enclosure",
slog.String("enclosure_url", link.Href),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
}
}
for _, mediaContent := range atomEntry.AllMediaContents() {
mediaURL := strings.TrimSpace(mediaContent.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
}
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
}
entries = append(entries, entry)
}
return entries
}

View File

@ -12,7 +12,6 @@ import (
func TestParseAtomSample(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
@ -20,7 +19,6 @@ func TestParseAtomSample(t *testing.T) {
<name>John Doe</name>
</author>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Atom-Powered Robots Run Amok</title>
<link href="http://example.org/2003/12/13/atom03"/>
@ -28,10 +26,9 @@ func TestParseAtomSample(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)))
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -93,7 +90,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -123,7 +120,7 @@ func TestParseEntryWithoutTitleButWithURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -154,7 +151,7 @@ func TestParseEntryWithoutTitleButWithSummary(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -187,7 +184,7 @@ func TestParseEntryWithoutTitleButWithXHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -206,7 +203,7 @@ func TestParseFeedURL(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -220,12 +217,31 @@ func TestParseFeedURL(t *testing.T) {
}
}
func TestParseFeedWithRelativeURL(t *testing.T) {
func TestParseFeedWithRelativeFeedURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link rel="alternate" type="text/html" href="https://example.org/"/>
<link rel="self" type="application/atom+xml" href="/feed"/>
<updated>2003-12-13T18:30:02Z</updated>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.FeedURL != "https://example.org/feed" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
}
func TestParseFeedWithRelativeSiteURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="/blog/atom.xml" rel="self" type="application/atom+xml"/>
<link href="/blog"/>
<link href="/blog "/>
<entry>
<title>Test</title>
@ -238,21 +254,53 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.FeedURL != "https://example.org/blog/atom.xml" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
t.Errorf("Incorrect feed URL, got: %q", feed.FeedURL)
}
if feed.SiteURL != "https://example.org/blog" {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
t.Errorf("Incorrect site URL, got: %q", feed.SiteURL)
}
if feed.Entries[0].URL != "https://example.org/blog/article.html" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
t.Errorf("Incorrect entry URL, got: %q", feed.Entries[0].URL)
}
}
func TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<link href="http://example.org "/>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "http://example.org" {
t.Errorf("Incorrect site URL, got: %q", feed.SiteURL)
}
}
func TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<link href="/blog/atom.xml " rel="self" type="application/atom+xml"/>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.FeedURL != "https://example.org/blog/atom.xml" {
t.Errorf("Incorrect site URL, got: %q", feed.FeedURL)
}
}
@ -272,7 +320,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -298,7 +346,7 @@ func TestParseEntryURLWithTextHTMLType(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -324,7 +372,7 @@ func TestParseEntryURLWithNoRelAndNoType(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -350,7 +398,7 @@ func TestParseEntryURLWithAlternateRel(t *testing.T) {
</feed>`
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -378,7 +426,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -412,7 +460,7 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -420,7 +468,7 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
expected := `AT&T bought by SBC!`
for i := range 2 {
if feed.Entries[i].Title != expected {
t.Errorf("Incorrect title for entry #%d, got: %q", i, feed.Entries[i].Title)
t.Errorf("Incorrect title for entry #%d, got: %q instead of %q", i, feed.Entries[i].Title, expected)
}
}
}
@ -430,45 +478,32 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<title type="html">&lt;code&gt;Code&lt;/code&gt; Test</title>
<link href="http://example.org/z"/>
</entry>
<entry>
<title type="html"><![CDATA[Test &#8220;Test&#8221;]]></title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<title type="html"><![CDATA[Test with &#8220;unicode quote&#8221;]]></title>
<link href="http://example.org/b"/>
</entry>
<entry>
<title>
<![CDATA[Entry title with space around CDATA]]>
</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<link href="http://example.org/c"/>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != "<code>Test</code> Test" {
if feed.Entries[0].Title != "Code Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
if feed.Entries[1].Title != "Test “Test”" {
if feed.Entries[1].Title != "Test with “unicode quote”" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
}
@ -497,13 +532,13 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != `This is <b>XHTML</b> content.` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
if feed.Entries[0].Title != `This is XHTML content.` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
@ -524,7 +559,7 @@ func TestParseEntryWithEmptyXHTMLTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -551,7 +586,7 @@ func TestParseEntryWithXHTMLTitleWithoutDiv(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -577,7 +612,7 @@ func TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -603,12 +638,12 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != `&#39;AT&amp;T&#39;` {
if feed.Entries[0].Title != `'AT&T'` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
@ -629,7 +664,7 @@ func TestParseEntryWithXHTMLSummary(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -644,39 +679,33 @@ func TestParseEntryWithHTMLSummary(t *testing.T) {
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">Example</title>
<title type="html">Example 1</title>
<link href="http://example.org/1"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt;&lt;/code&gt;</summary>
<summary type="html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt; myvar;&lt;/code&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<title type="html">Example 2</title>
<link href="http://example.org/2"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text/html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt;&lt;/code&gt;</summary>
<summary type="text/html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt; myvar;&lt;/code&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<title type="html">Example 3</title>
<link href="http://example.org/3"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="html"><![CDATA[<code>std::unique_ptr&lt;S&gt;</code>]]></summary>
<summary type="html"><![CDATA[<code>std::unique_ptr&lt;S&gt; myvar;</code>]]></summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
expected := `<code>std::unique_ptr&lt;S&gt;</code>`
if len(feed.Entries) != 3 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
expected := `<code>std::unique_ptr&lt;S&gt; myvar;</code>`
for i := range 3 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
@ -723,12 +752,12 @@ func TestParseEntryWithTextSummary(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
expected := `AT&T <S>`
for i := range 4 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
@ -747,7 +776,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
<link href="http://example.org/a"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content>AT&amp;T &lt;S&gt;</content>
<content>AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>
</entry>
<entry>
@ -755,7 +784,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="text">AT&amp;T &lt;S&gt;</content>
<content type="text">AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>
</entry>
<entry>
@ -763,7 +792,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
<link href="http://example.org/c"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="text/plain">AT&amp;T &lt;S&gt;</content>
<content type="text/plain">AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>
</entry>
<entry>
@ -771,20 +800,20 @@ func TestParseEntryWithTextContent(t *testing.T) {
<link href="http://example.org/d"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content><![CDATA[AT&T <S>]]></content>
<content><![CDATA[AT&T <strong>Strong Element</strong>]]></content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
expected := `AT&T <strong>Strong Element</strong>`
for i := range 4 {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
t.Errorf("Incorrect content for entry #%d, got: %q instead of %q", i, feed.Entries[i].Content, expected)
}
}
}
@ -821,7 +850,7 @@ func TestParseEntryWithHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -852,7 +881,7 @@ func TestParseEntryWithXHTMLContent(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -881,7 +910,7 @@ func TestParseEntryWithAuthorName(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -910,7 +939,7 @@ func TestParseEntryWithoutAuthorName(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -925,7 +954,6 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
@ -938,10 +966,9 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
<name>Bob</name>
</author>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -951,7 +978,7 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
}
}
func TestParseEntryWithoutAuthor(t *testing.T) {
func TestParseFeedWithEntryWithoutAuthor(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
@ -959,17 +986,15 @@ func TestParseEntryWithoutAuthor(t *testing.T) {
<author>
<name>John Doe</name>
</author>
<entry>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -990,17 +1015,18 @@ func TestParseFeedWithMultipleAuthors(t *testing.T) {
<author>
<name>Bob</name>
</author>
<author>
<name>Bob</name>
</author>
<entry>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1015,17 +1041,15 @@ func TestParseFeedWithoutAuthor(t *testing.T) {
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1075,13 +1099,13 @@ func TestParseEntryWithEnclosures(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
@ -1116,6 +1140,89 @@ func TestParseEntryWithEnclosures(t *testing.T) {
}
}
func TestParseEntryWithRelativeEnclosureURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://www.example.org/myfeed</id>
<title>My Podcast Feed</title>
<link href="https://example.org" />
<link rel="self" href="https://example.org/myfeed" />
<entry>
<id>https://www.example.org/entries/1</id>
<title>Atom 1.0</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="https://www.example.org/entries/1" />
<link rel="enclosure"
type="audio/mpeg"
title="MP3"
href=" /myaudiofile.mp3 "
length="1234" />
</content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if len(feed.Entries[0].Enclosures) != 1 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
if feed.Entries[0].Enclosures[0].URL != "https://example.org/myaudiofile.mp3" {
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
}
}
func TestParseEntryWithDuplicateEnclosureURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>http://www.example.org/myfeed</id>
<title>My Podcast Feed</title>
<link href="http://example.org" />
<link rel="self" href="http://example.org/myfeed" />
<entry>
<id>http://www.example.org/entries/1</id>
<title>Atom 1.0</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="http://www.example.org/entries/1" />
<link rel="enclosure"
type="audio/mpeg"
title="MP3"
href="http://www.example.org/myaudiofile.mp3"
length="1234" />
<link rel="enclosure"
type="audio/mpeg"
title="MP3"
href=" http://www.example.org/myaudiofile.mp3 "
length="1234" />
</content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if len(feed.Entries[0].Enclosures) != 1 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
}
}
func TestParseEntryWithoutEnclosureURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
@ -1135,7 +1242,7 @@ func TestParseEntryWithoutEnclosureURL(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1168,7 +1275,7 @@ func TestParseEntryWithPublished(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1194,7 +1301,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1206,7 +1313,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err == nil {
t.Error("Parse should returns an error")
}
@ -1221,7 +1328,7 @@ func TestParseTitleWithSingleQuote(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1240,7 +1347,7 @@ func TestParseTitleWithEncodedSingleQuote(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1259,7 +1366,7 @@ func TestParseTitleWithSingleQuoteAndHTMLType(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1278,7 +1385,7 @@ func TestParseWithHTMLEntity(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1297,7 +1404,7 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
</feed>
`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1310,44 +1417,41 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
func TestParseMediaGroup(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<id>http://www.example.org/myfeed</id>
<id>https://www.example.org/myfeed</id>
<title>My Video Feed</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="http://example.org" />
<link rel="self" href="http://example.org/myfeed" />
<link href="https://example.org" />
<link rel="self" href="https://example.org/myfeed" />
<entry>
<id>http://www.example.org/entries/1</id>
<id>https://www.example.org/entries/1</id>
<title>Some Video</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="http://www.example.org/entries/1" />
<link href="https://www.example.org/entries/1" />
<media:group>
<media:title>Another title</media:title>
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
<media:content url=" /v/efg " type="application/x-shockwave-flash" width="640" height="390"/>
<media:content url=" " type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://www.example.org/duplicate-thumbnail.jpg" width="480" height="360"/>
<media:thumbnail url="https://www.example.org/duplicate-thumbnail.jpg" width="480" height="360"/>
<media:thumbnail url=" /thumbnail2.jpg " width="480" height="360"/>
<media:thumbnail url=" " width="480" height="360"/>
<media:description>Some description
A website: http://example.org/</media:description>
</media:group>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
}
if len(feed.Entries[0].Enclosures) != 2 {
if len(feed.Entries[0].Enclosures) != 4 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
@ -1356,8 +1460,10 @@ A website: http://example.org/</media:description>
mimeType string
size int64
}{
{"https://example.org/thumbnail.jpg", "image/*", 0},
{"https://www.example.org/duplicate-thumbnail.jpg", "image/*", 0},
{"https://example.org/thumbnail2.jpg", "image/*", 0},
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
{"https://example.org/v/efg", "application/x-shockwave-flash", 0},
}
for index, enclosure := range feed.Entries[0].Enclosures {
@ -1378,42 +1484,41 @@ A website: http://example.org/</media:description>
func TestParseMediaElements(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<id>http://www.example.org/myfeed</id>
<id>https://www.example.org/myfeed</id>
<title>My Video Feed</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="http://example.org" />
<link rel="self" href="http://example.org/myfeed" />
<link href="https://example.org" />
<link rel="self" href="https://example.org/myfeed" />
<entry>
<id>http://www.example.org/entries/1</id>
<id>https://www.example.org/entries/1</id>
<title>Some Video</title>
<updated>2005-07-15T12:00:00Z</updated>
<link href="http://www.example.org/entries/1" />
<link href="https://www.example.org/entries/1" />
<media:title>Another title</media:title>
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
<media:content url=" /relative/media.mp4 " type="application/x-shockwave-flash" width="640" height="390"/>
<media:content url=" " type="application/x-shockwave-flash" width="640" height="390"/>
<media:thumbnail url="https://example.org/duplicated-thumbnail.jpg" width="480" height="360"/>
<media:thumbnail url=" https://example.org/duplicated-thumbnail.jpg " width="480" height="360"/>
<media:thumbnail url=" " width="480" height="360"/>
<media:peerLink type="application/x-bittorrent" href=" http://www.example.org/sampleFile.torrent " />
<media:peerLink type="application/x-bittorrent" href=" /sampleFile2.torrent" />
<media:peerLink type="application/x-bittorrent" href=" " />
<media:description>Some description
A website: http://example.org/</media:description>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
}
if len(feed.Entries[0].Enclosures) != 2 {
if len(feed.Entries[0].Enclosures) != 5 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
@ -1422,8 +1527,11 @@ A website: http://example.org/</media:description>
mimeType string
size int64
}{
{"https://example.org/thumbnail.jpg", "image/*", 0},
{"https://example.org/duplicated-thumbnail.jpg", "image/*", 0},
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
{"https://example.org/relative/media.mp4", "application/x-shockwave-flash", 0},
{"http://www.example.org/sampleFile.torrent", "application/x-bittorrent", 0},
{"https://example.org/sampleFile2.torrent", "application/x-bittorrent", 0},
}
for index, enclosure := range feed.Entries[0].Enclosures {
@ -1467,7 +1575,7 @@ func TestParseRepliesLinkRelationWithHTMLType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1511,7 +1619,7 @@ func TestParseRepliesLinkRelationWithXHTMLType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1550,7 +1658,7 @@ func TestParseRepliesLinkRelationWithNoType(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1590,7 +1698,7 @@ func TestAbsoluteCommentsURL(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
@ -1608,51 +1716,73 @@ func TestAbsoluteCommentsURL(t *testing.T) {
}
}
func TestParseFeedWithCategories(t *testing.T) {
func TestParseItemWithCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<author>
<name>Alice</name>
</author>
<author>
<name>Bob</name>
</author>
<entry>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<link href="http://www.example.org/entries/1" />
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<category term='Tech' />
<category term='ZZZZ' />
<category term='Technology' label='Science' />
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries[0].Tags) != 2 {
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Tech"
expected := "Science"
result := feed.Entries[0].Tags[0]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
}
expected = "Science"
expected = "ZZZZ"
result = feed.Entries[0].Tags[1]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
}
}
func TestParseFeedWithCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<category term='Test' label='Some Label' />
<category term='Test' label='Some Label' />
<category term='Test' label='Some Label' />
<entry>
<link href="http://www.example.org/entries/1" />
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries[0].Tags) != 1 {
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Some Label"
result := feed.Entries[0].Tags[0]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
}
}
func TestParseFeedWithIconURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
@ -1661,7 +1791,7 @@ func TestParseFeedWithIconURL(t *testing.T) {
<icon>http://example.org/icon.png</icon>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}

View File

@ -3,77 +3,91 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import "strings"
import (
"strings"
)
type atomPerson struct {
Name string `xml:"name"`
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.2
type AtomPerson struct {
// The "atom:name" element's content conveys a human-readable name for the author.
// It MAY be the name of a corporation or other entity no individual authors can be named.
// Person constructs MUST contain exactly one "atom:name" element, whose content MUST be a string.
Name string `xml:"name"`
// The "atom:email" element's content conveys an e-mail address associated with the Person construct.
// Person constructs MAY contain an atom:email element, but MUST NOT contain more than one.
// Its content MUST be an e-mail address [RFC2822].
// Ordering of the element children of Person constructs MUST NOT be considered significant.
Email string `xml:"email"`
}
func (a *atomPerson) String() string {
name := ""
switch {
case a.Name != "":
name = a.Name
case a.Email != "":
name = a.Email
func (a *AtomPerson) PersonName() string {
name := strings.TrimSpace(a.Name)
if name != "" {
return name
}
return strings.TrimSpace(name)
return strings.TrimSpace(a.Email)
}
type atomAuthors []*atomPerson
type AtomPersons []*AtomPerson
func (a atomAuthors) String() string {
var authors []string
func (a AtomPersons) PersonNames() []string {
var names []string
authorNamesMap := make(map[string]bool)
for _, person := range a {
authors = append(authors, person.String())
personName := person.PersonName()
if _, ok := authorNamesMap[personName]; !ok {
names = append(names, personName)
authorNamesMap[personName] = true
}
}
return strings.Join(authors, ", ")
return names
}
type atomLink struct {
URL string `xml:"href,attr"`
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
type AtomLink struct {
Href string `xml:"href,attr"`
Type string `xml:"type,attr"`
Rel string `xml:"rel,attr"`
Length string `xml:"length,attr"`
Title string `xml:"title,attr"`
}
type atomLinks []*atomLink
type AtomLinks []*AtomLink
func (a atomLinks) originalLink() string {
func (a AtomLinks) OriginalLink() string {
for _, link := range a {
if strings.EqualFold(link.Rel, "alternate") {
return strings.TrimSpace(link.URL)
return strings.TrimSpace(link.Href)
}
if link.Rel == "" && (link.Type == "" || link.Type == "text/html") {
return strings.TrimSpace(link.URL)
return strings.TrimSpace(link.Href)
}
}
return ""
}
func (a atomLinks) firstLinkWithRelation(relation string) string {
func (a AtomLinks) firstLinkWithRelation(relation string) string {
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
return strings.TrimSpace(link.URL)
return strings.TrimSpace(link.Href)
}
}
return ""
}
func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
func (a AtomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
for _, contentType := range contentTypes {
if strings.EqualFold(link.Type, contentType) {
return strings.TrimSpace(link.URL)
return strings.TrimSpace(link.Href)
}
}
}
@ -81,3 +95,61 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
return ""
}
func (a AtomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
var links []*AtomLink
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
link.Href = strings.TrimSpace(link.Href)
if link.Href != "" {
links = append(links, link)
}
}
}
return links
}
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2
type AtomCategory struct {
// The "term" attribute is a string that identifies the category to
// which the entry or feed belongs. Category elements MUST have a
// "term" attribute.
Term string `xml:"term,attr"`
// The "scheme" attribute is an IRI that identifies a categorization
// scheme. Category elements MAY have a "scheme" attribute.
Scheme string `xml:"scheme,attr"`
// The "label" attribute provides a human-readable label for display in
// end-user applications. The content of the "label" attribute is
// Language-Sensitive. Entities such as "&amp;" and "&lt;" represent
// their corresponding characters ("&" and "<", respectively), not
// markup. Category elements MAY have a "label" attribute.
Label string `xml:"label,attr"`
}
type AtomCategories []AtomCategory
func (ac AtomCategories) CategoryNames() []string {
var categories []string
for _, category := range ac {
label := strings.TrimSpace(category.Label)
if label != "" {
categories = append(categories, label)
} else {
term := strings.TrimSpace(category.Term)
if term != "" {
categories = append(categories, term)
}
}
}
return categories
}

View File

@ -4,7 +4,6 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/xml"
"fmt"
"io"
@ -12,45 +11,20 @@ import (
xml_decoder "miniflux.app/v2/internal/reader/xml"
)
type atomFeed interface {
Transform(baseURL string) *model.Feed
}
// Parse returns a normalized feed struct from a Atom feed.
func Parse(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
var rawFeed atomFeed
if getAtomFeedVersion(r) == "0.3" {
rawFeed = new(atom03Feed)
} else {
rawFeed = new(atom10Feed)
}
r.Seek(0, io.SeekStart)
if err := xml_decoder.NewXMLDecoder(r).Decode(rawFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
}
return rawFeed.Transform(baseURL), nil
}
func getAtomFeedVersion(data io.ReadSeeker) string {
decoder := xml_decoder.NewXMLDecoder(data)
for {
token, _ := decoder.Token()
if token == nil {
break
func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) {
switch version {
case "0.3":
atomFeed := new(Atom03Feed)
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse Atom 0.3 feed: %w", err)
}
if element, ok := token.(xml.StartElement); ok {
if element.Name.Local == "feed" {
for _, attr := range element.Attr {
if attr.Name.Local == "version" && attr.Value == "0.3" {
return "0.3"
}
}
return "1.0"
}
return NewAtom03Adapter(atomFeed).BuildFeed(baseURL), nil
default:
atomFeed := new(Atom10Feed)
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
}
return NewAtom10Adapter(atomFeed).BuildFeed(baseURL), nil
}
return "1.0"
}

View File

@ -1,61 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"testing"
)
func TestDetectAtom10(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<author>
<name>John Doe</name>
</author>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Atom-Powered Robots Run Amok</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "1.0" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}
func TestDetectAtom03(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<title>dive into mark</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
<issued>2003-12-13T08:29:29-04:00</issued>
<modified>2003-12-13T18:30:02Z</modified>
<summary type="text/plain">This is a test</summary>
<content type="text/html" mode="escaped"><![CDATA[<p>HTML content</p>]]></content>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "0.3" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}

View File

@ -3,29 +3,13 @@
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
import (
"strings"
"miniflux.app/v2/internal/reader/sanitizer"
)
// DublinCoreFeedElement represents Dublin Core feed XML elements.
type DublinCoreFeedElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ channel>creator"`
type DublinCoreChannelElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
}
func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(feed.DublinCoreCreator))
}
// DublinCoreItemElement represents Dublin Core entry XML elements.
type DublinCoreItemElement struct {
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
}
func (item *DublinCoreItemElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(item.DublinCoreCreator))
}

View File

@ -35,8 +35,3 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
// Transform document to UTF-8 from the specified encoding in XML prolog.
return charset.NewReaderLabel(charsetLabel, r)
}
// CharsetReaderFromContentType is used when the encoding is not specified for the input document.
func CharsetReaderFromContentType(contentType string, input io.Reader) (io.Reader, error) {
return charset.NewReader(input, contentType)
}

View File

@ -0,0 +1,55 @@
package fetcher
import (
"compress/gzip"
"io"
"github.com/andybalholm/brotli"
)
type brotliReadCloser struct {
body io.ReadCloser
brotliReader io.Reader
}
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
return &brotliReadCloser{
body: body,
brotliReader: brotli.NewReader(body),
}
}
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
return b.brotliReader.Read(p)
}
func (b *brotliReadCloser) Close() error {
return b.body.Close()
}
type gzipReadCloser struct {
body io.ReadCloser
gzipReader io.Reader
gzipErr error
}
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
return &gzipReadCloser{body: body}
}
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
if gz.gzipReader == nil {
if gz.gzipErr == nil {
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
}
if gz.gzipErr != nil {
return 0, gz.gzipErr
}
}
return gz.gzipReader.Read(p)
}
func (gz *gzipReadCloser) Close() error {
return gz.body.Close()
}

View File

@ -169,6 +169,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
}
req.Header = r.headers
req.Header.Set("Accept-Encoding", "br, gzip")
req.Header.Set("Accept", defaultAcceptHeader)
req.Header.Set("Connection", "close")

View File

@ -8,8 +8,12 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"miniflux.app/v2/internal/locale"
)
@ -69,12 +73,31 @@ func (r *ResponseHandler) Close() {
}
}
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
slog.Debug("Request response",
slog.String("effective_url", r.EffectiveURL()),
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
slog.String("content_encoding", contentEncoding),
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
)
reader := r.httpResponse.Body
switch contentEncoding {
case "br":
reader = NewBrotliReadCloser(r.httpResponse.Body)
case "gzip":
reader = NewGzipReadCloser(r.httpResponse.Body)
}
return http.MaxBytesReader(nil, reader, maxBodySize)
}
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
return r.getReader(maxBodySize)
}
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
limitedReader := r.getReader(maxBodySize)
buffer, err := io.ReadAll(limitedReader)
if err != nil && err != io.EOF {
@ -94,23 +117,18 @@ func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.Localized
func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
if r.clientErr != nil {
switch r.clientErr.(type) {
case x509.CertificateInvalidError, x509.HostnameError:
switch {
case isSSLError(r.clientErr):
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.tls_error", r.clientErr)
case *net.OpError:
case isNetworkError(r.clientErr):
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_operation", r.clientErr)
case net.Error:
networkErr := r.clientErr.(net.Error)
if networkErr.Timeout() {
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr)
}
}
if errors.Is(r.clientErr, io.EOF) {
case os.IsTimeout(r.clientErr):
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr)
case errors.Is(r.clientErr, io.EOF):
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_empty_response")
default:
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
}
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
}
switch r.httpResponse.StatusCode {
@ -145,3 +163,32 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
return nil
}
func isNetworkError(err error) bool {
if _, ok := err.(*url.Error); ok {
return true
}
if err == io.EOF {
return true
}
var opErr *net.OpError
if ok := errors.As(err, &opErr); ok {
return true
}
return false
}
func isSSLError(err error) bool {
var certErr x509.UnknownAuthorityError
if errors.As(err, &certErr) {
return true
}
var hostErr x509.HostnameError
if errors.As(err, &hostErr) {
return true
}
var algErr x509.InsecureAlgorithmError
return errors.As(err, &algErr)
}

View File

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googleplay // import "miniflux.app/v2/internal/reader/googleplay"
// Specs:
// https://support.google.com/googleplay/podcasts/answer/6260341
// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd
type GooglePlayChannelElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"`
GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"`
}
type GooglePlayItemElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"`
GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"`
GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"`
}
type GooglePlayImageElement struct {
Href string `xml:"href,attr"`
}
type GooglePlayCategoryElement struct {
Text string `xml:"text,attr"`
}

View File

@ -236,14 +236,18 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(originalFeed.Cookie)
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(originalFeed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
if !ignoreHTTPCache {
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
}
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
defer responseHandler.Close()
@ -261,7 +265,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
return localizedError
}
if originalFeed.IgnoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
if ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
slog.Debug("Feed modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),

View File

@ -15,11 +15,11 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/encoding"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/urllib"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html/charset"
)
type IconFinder struct {
@ -191,7 +191,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
"link[rel='apple-touch-icon-precomposed.png']",
}
htmlDocumentReader, err := encoding.CharsetReaderFromContentType(contentType, body)
htmlDocumentReader, err := charset.NewReader(body, contentType)
if err != nil {
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
}

View File

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package itunes // import "miniflux.app/v2/internal/reader/itunes"
import "strings"
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
type ItunesChannelElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesBlock string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd block"`
ItunesCategories []ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
ItunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
ItunesCopyright string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd copyright"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
Keywords string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd keywords"`
ItunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
ItunesOwner ItunesOwnerElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
}
func (i *ItunesChannelElement) GetItunesCategories() []string {
var categories []string
for _, category := range i.ItunesCategories {
categories = append(categories, category.Text)
if category.SubCategory != nil {
categories = append(categories, category.SubCategory.Text)
}
}
return categories
}
type ItunesItemElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
ItunesSubtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesTranscript string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd transcript"`
}
type ItunesImageElement struct {
Href string `xml:"href,attr"`
}
type ItunesCategoryElement struct {
Text string `xml:"text,attr"`
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
}
type ItunesOwnerElement struct {
Name string `xml:"name"`
Email string `xml:"email"`
}
func (i *ItunesOwnerElement) String() string {
var name string
switch {
case i.Name != "":
name = i.Name
case i.Email != "":
name = i.Email
}
return strings.TrimSpace(name)
}

View File

@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/reader/json"
import (
"log/slog"
"slices"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type JSONAdapter struct {
jsonFeed *JSONFeed
}
func NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {
return &JSONAdapter{jsonFeed}
}
func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
feed := &model.Feed{
Title: strings.TrimSpace(j.jsonFeed.Title),
FeedURL: strings.TrimSpace(j.jsonFeed.FeedURL),
SiteURL: strings.TrimSpace(j.jsonFeed.HomePageURL),
}
if feed.FeedURL == "" {
feed.FeedURL = strings.TrimSpace(baseURL)
}
// Fallback to the feed URL if the site URL is empty.
if feed.SiteURL == "" {
feed.SiteURL = feed.FeedURL
}
if feedURL, err := urllib.AbsoluteURL(baseURL, feed.FeedURL); err == nil {
feed.FeedURL = feedURL
}
if siteURL, err := urllib.AbsoluteURL(baseURL, feed.SiteURL); err == nil {
feed.SiteURL = siteURL
}
// Fallback to the feed URL if the title is empty.
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the icon URL if present.
for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
iconURL = strings.TrimSpace(iconURL)
if iconURL != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, iconURL); err == nil {
feed.IconURL = absoluteIconURL
break
}
}
}
for _, item := range j.jsonFeed.Items {
entry := model.NewEntry()
entry.Title = strings.TrimSpace(item.Title)
entry.URL = strings.TrimSpace(item.URL)
// Make sure the entry URL is absolute.
if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = entryURL
}
// The entry title is optional, so we need to find a fallback.
if entry.Title == "" {
for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
if value != "" {
entry.Title = sanitizer.TruncateHTML(value, 100)
}
}
}
// Fallback to the entry URL if the title is empty.
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry content.
for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
value = strings.TrimSpace(value)
if value != "" {
entry.Content = value
break
}
}
// Populate the entry date.
for _, value := range []string{item.DatePublished, item.DateModified} {
value = strings.TrimSpace(value)
if value != "" {
if date, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = date
break
}
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate the entry author.
itemAuthors := j.jsonFeed.Authors
itemAuthors = append(itemAuthors, item.Authors...)
itemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)
var authorNames []string
for _, author := range itemAuthors {
authorName := strings.TrimSpace(author.Name)
if authorName != "" {
authorNames = append(authorNames, authorName)
}
}
slices.Sort(authorNames)
authorNames = slices.Compact(authorNames)
entry.Author = strings.Join(authorNames, ", ")
// Populate the entry enclosures.
for _, attachment := range item.Attachments {
attachmentURL := strings.TrimSpace(attachment.URL)
if attachmentURL != "" {
if absoluteAttachmentURL, err := urllib.AbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteAttachmentURL,
MimeType: attachment.MimeType,
Size: attachment.Size,
})
}
}
}
// Populate the entry tags.
for _, tag := range item.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
entry.Tags = append(entry.Tags, tag)
}
}
// Generate a hash for the entry.
for _, value := range []string{item.ID, item.URL, item.ContentText + item.ContentHTML + item.Summary} {
value = strings.TrimSpace(value)
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}

View File

@ -3,207 +3,141 @@
package json // import "miniflux.app/v2/internal/reader/json"
import (
"log/slog"
"strings"
"time"
// JSON Feed specs:
// https://www.jsonfeed.org/version/1.1/
// https://www.jsonfeed.org/version/1/
type JSONFeed struct {
// Version is the URL of the version of the format the feed uses.
// This should appear at the very top, though we recognize that not all JSON generators allow for ordering.
Version string `json:"version"`
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// Title is the name of the feed, which will often correspond to the name of the website.
Title string `json:"title"`
type jsonFeed struct {
Version string `json:"version"`
Title string `json:"title"`
SiteURL string `json:"home_page_url"`
IconURL string `json:"icon"`
FaviconURL string `json:"favicon"`
FeedURL string `json:"feed_url"`
Authors []jsonAuthor `json:"authors"`
Author jsonAuthor `json:"author"`
Items []jsonItem `json:"items"`
// HomePageURL is the URL of the resource that the feed describes.
// This resource may or may not actually be a “home” page, but it should be an HTML page.
HomePageURL string `json:"home_page_url"`
// FeedURL is the URL of the feed, and serves as the unique identifier for the feed.
FeedURL string `json:"feed_url"`
// Description provides more detail, beyond the title, on what the feed is about.
Description string `json:"description"`
// IconURL is the URL of an image for the feed suitable to be used in a timeline, much the way an avatar might be used.
IconURL string `json:"icon"`
// FaviconURL is the URL of an image for the feed suitable to be used in a source list. It should be square and relatively small.
FaviconURL string `json:"favicon"`
// Authors specifies one or more feed authors. The author object has several members.
Authors []JSONAuthor `json:"authors"` // JSON Feed v1.1
// Author specifies the feed author. The author object has several members.
// JSON Feed v1 (deprecated)
Author JSONAuthor `json:"author"`
// Language is the primary language for the feed in the format specified in RFC 5646.
// The value is usually a 2-letter language tag from ISO 639-1, optionally followed by a region tag. (Examples: en or en-US.)
Language string `json:"language"`
// Expired is a boolean value that specifies whether or not the feed is finished.
Expired bool `json:"expired"`
// Items is an array, each representing an individual item in the feed.
Items []JSONItem `json:"items"`
// Hubs describes endpoints that can be used to subscribe to real-time notifications from the publisher of this feed.
Hubs []JSONHub `json:"hubs"`
}
type jsonAuthor struct {
type JSONAuthor struct {
// Author's name.
Name string `json:"name"`
URL string `json:"url"`
// Author's website URL (Blog or micro-blog).
WebsiteURL string `json:"url"`
// Author's avatar URL.
AvatarURL string `json:"avatar"`
}
type jsonItem struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Summary string `json:"summary"`
Text string `json:"content_text"`
HTML string `json:"content_html"`
DatePublished string `json:"date_published"`
DateModified string `json:"date_modified"`
Authors []jsonAuthor `json:"authors"`
Author jsonAuthor `json:"author"`
Attachments []jsonAttachment `json:"attachments"`
Tags []string `json:"tags"`
type JSONHub struct {
// Type defines the protocol used to talk with the hub: "rssCloud" or "WebSub".
Type string `json:"type"`
// URL is the location of the hub.
URL string `json:"url"`
}
type jsonAttachment struct {
URL string `json:"url"`
type JSONItem struct {
// Unique identifier for the item.
// Ideally, the id is the full URL of the resource described by the item, since URLs make great unique identifiers.
ID string `json:"id"`
// URL of the resource described by the item.
URL string `json:"url"`
// ExternalURL is the URL of a page elsewhere.
// This is especially useful for linkblogs.
// If url links to where youre talking about a thing, then external_url links to the thing youre talking about.
ExternalURL string `json:"external_url"`
// Title of the item (optional).
// Microblog items in particular may omit titles.
Title string `json:"title"`
// ContentHTML is the HTML body of the item.
ContentHTML string `json:"content_html"`
// ContentText is the text body of the item.
ContentText string `json:"content_text"`
// Summary is a plain text sentence or two describing the item.
Summary string `json:"summary"`
// ImageURL is the URL of the main image for the item.
ImageURL string `json:"image"`
// BannerImageURL is the URL of an image to use as a banner.
BannerImageURL string `json:"banner_image"`
// DatePublished is the date the item was published.
DatePublished string `json:"date_published"`
// DateModified is the date the item was modified.
DateModified string `json:"date_modified"`
// Language is the language of the item.
Language string `json:"language"`
// Authors is an array of JSONAuthor.
Authors []JSONAuthor `json:"authors"`
// Author is a JSONAuthor.
// JSON Feed v1 (deprecated)
Author JSONAuthor `json:"author"`
// Tags is an array of strings.
Tags []string `json:"tags"`
// Attachments is an array of JSONAttachment.
Attachments []JSONAttachment `json:"attachments"`
}
type JSONAttachment struct {
// URL of the attachment.
URL string `json:"url"`
// MIME type of the attachment.
MimeType string `json:"mime_type"`
Title string `json:"title"`
Size int64 `json:"size_in_bytes"`
Duration int `json:"duration_in_seconds"`
}
func (j *jsonFeed) GetAuthor() string {
if len(j.Authors) > 0 {
return (getAuthor(j.Authors[0]))
}
return getAuthor(j.Author)
}
func (j *jsonFeed) Transform(baseURL string) *model.Feed {
var err error
feed := new(model.Feed)
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, j.FeedURL)
if err != nil {
feed.FeedURL = j.FeedURL
}
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, j.SiteURL)
if err != nil {
feed.SiteURL = j.SiteURL
}
feed.IconURL = strings.TrimSpace(j.IconURL)
if feed.IconURL == "" {
feed.IconURL = strings.TrimSpace(j.FaviconURL)
}
feed.Title = strings.TrimSpace(j.Title)
if feed.Title == "" {
feed.Title = feed.SiteURL
}
for _, item := range j.Items {
entry := item.Transform()
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL)
if err == nil {
entry.URL = entryURL
}
if entry.Author == "" {
entry.Author = j.GetAuthor()
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}
func (j *jsonItem) GetDate() time.Time {
for _, value := range []string{j.DatePublished, j.DateModified} {
if value != "" {
d, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", j.URL),
slog.Any("error", err),
)
return time.Now()
}
return d
}
}
return time.Now()
}
func (j *jsonItem) GetAuthor() string {
if len(j.Authors) > 0 {
return getAuthor(j.Authors[0])
}
return getAuthor(j.Author)
}
func (j *jsonItem) GetHash() string {
for _, value := range []string{j.ID, j.URL, j.Text + j.HTML + j.Summary} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
func (j *jsonItem) GetTitle() string {
if j.Title != "" {
return j.Title
}
for _, value := range []string{j.Summary, j.Text, j.HTML} {
if value != "" {
return sanitizer.TruncateHTML(value, 100)
}
}
return j.URL
}
func (j *jsonItem) GetContent() string {
for _, value := range []string{j.HTML, j.Text, j.Summary} {
if value != "" {
return value
}
}
return ""
}
func (j *jsonItem) GetEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
for _, attachment := range j.Attachments {
if attachment.URL == "" {
continue
}
enclosures = append(enclosures, &model.Enclosure{
URL: attachment.URL,
MimeType: attachment.MimeType,
Size: attachment.Size,
})
}
return enclosures
}
func (j *jsonItem) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = j.URL
entry.Date = j.GetDate()
entry.Author = j.GetAuthor()
entry.Hash = j.GetHash()
entry.Content = j.GetContent()
entry.Title = strings.TrimSpace(j.GetTitle())
entry.Enclosures = j.GetEnclosures()
if len(j.Tags) > 0 {
entry.Tags = j.Tags
}
return entry
}
func getAuthor(author jsonAuthor) string {
if author.Name != "" {
return strings.TrimSpace(author.Name)
}
return ""
// Title of the attachment.
Title string `json:"title"`
// Size of the attachment in bytes.
Size int64 `json:"size_in_bytes"`
// Duration of the attachment in seconds.
Duration int `json:"duration_in_seconds"`
}

View File

@ -13,10 +13,10 @@ import (
// Parse returns a normalized feed struct from a JSON feed.
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
feed := new(jsonFeed)
if err := json.NewDecoder(data).Decode(&feed); err != nil {
jsonFeed := new(JSONFeed)
if err := json.NewDecoder(data).Decode(&jsonFeed); err != nil {
return nil, fmt.Errorf("json: unable to parse feed: %w", err)
}
return feed.Transform(baseURL), nil
return NewJSONAdapter(jsonFeed).BuildFeed(baseURL), nil
}

View File

@ -10,7 +10,7 @@ import (
"time"
)
func TestParseJsonFeed(t *testing.T) {
func TestParseJsonFeedVersion1(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -49,7 +49,7 @@ func TestParseJsonFeed(t *testing.T) {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
}
if feed.IconURL != "https://micro.blog/jsonfeed/avatar.jpg" {
if feed.IconURL != "https://micro.blog/jsonfeed/favicon.png" {
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
}
@ -177,7 +177,157 @@ func TestParsePodcast(t *testing.T) {
}
}
func TestParseEntryWithoutAttachmentURL(t *testing.T) {
func TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json ",
"items": []
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.FeedURL != "https://example.org/feed.json" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
}
func TestParseFeedWithRelativeFeedURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "/feed.json",
"items": []
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.FeedURL != "https://example.org/feed.json" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
}
func TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/ ",
"feed_url": "https://example.org/feed.json",
"items": []
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "https://example.org/" {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
}
}
func TestParseFeedWithRelativeSiteURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "/home ",
"feed_url": "https://example.org/feed.json",
"items": []
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "https://example.org/home" {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
}
}
func TestParseFeedWithoutTitle(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "2347259",
"url": "https://example.org/2347259",
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
"date_published": "2016-02-09T14:22:00-07:00"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Title != "https://example.org/" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseFeedWithoutHomePage(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"feed_url": "https://example.org/feed.json",
"title": "Some test",
"items": [
{
"id": "2347259",
"url": "https://example.org/2347259",
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
"date_published": "2016-02-09T14:22:00-07:00"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "https://example.org/feed.json" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseFeedWithoutFeedURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "Some test",
"items": [
{
"id": "2347259",
"url": "https://example.org/2347259",
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
"date_published": "2016-02-09T14:22:00-07:00"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "https://example.org/feed.json" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseItemWithoutAttachmentURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"user_comment": "This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json",
@ -216,7 +366,7 @@ func TestParseEntryWithoutAttachmentURL(t *testing.T) {
}
}
func TestParseFeedWithRelativeURL(t *testing.T) {
func TestParseItemWithRelativeURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "Example",
@ -241,7 +391,7 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
}
}
func TestParseAuthor(t *testing.T) {
func TestParseItemWithLegacyAuthorField(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
@ -277,7 +427,7 @@ func TestParseAuthor(t *testing.T) {
}
}
func TestParseAuthors(t *testing.T) {
func TestParseItemWithMultipleAuthorFields(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1.1",
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
@ -285,11 +435,11 @@ func TestParseAuthors(t *testing.T) {
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"author": {
"name": "This field is deprecated, use authors",
"name": "Deprecated Author Field",
"url": "http://example.org/",
"avatar": "https://example.org/avatar.png"
},
"authors": [
"authors": [
{
"name": "Brent Simmons",
"url": "http://example.org/",
@ -315,14 +465,15 @@ func TestParseAuthors(t *testing.T) {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Author != "Brent Simmons" {
if feed.Entries[0].Author != "Brent Simmons, Deprecated Author Field" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseFeedWithoutTitle(t *testing.T) {
func TestParseItemWithMultipleDuplicateAuthors(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"version": "https://jsonfeed.org/version/1.1",
"title": "Example",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
@ -330,7 +481,24 @@ func TestParseFeedWithoutTitle(t *testing.T) {
"id": "2347259",
"url": "https://example.org/2347259",
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
"date_published": "2016-02-09T14:22:00-07:00"
"date_published": "2016-02-09T14:22:00-07:00",
"authors": [
{
"name": "Author B",
"url": "http://example.org/",
"avatar": "https://example.org/avatar.png"
},
{
"name": "Author A",
"url": "http://example.org/",
"avatar": "https://example.org/avatar.png"
},
{
"name": "Author B",
"url": "http://example.org/",
"avatar": "https://example.org/avatar.png"
}
]
}
]
}`
@ -340,12 +508,16 @@ func TestParseFeedWithoutTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Title != "https://example.org/" {
t.Errorf("Incorrect title, got: %s", feed.Title)
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Author != "Author A, Author B" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseFeedItemWithInvalidDate(t *testing.T) {
func TestParseItemWithInvalidDate(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -376,34 +548,7 @@ func TestParseFeedItemWithInvalidDate(t *testing.T) {
}
}
func TestParseFeedItemWithoutID(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"content_text": "Some text."
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "13b4c5aecd1b6d749afcee968fbf9c80f1ed1bbdbe1aaf25cb34ebd01144bbe9" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
}
func TestParseFeedItemWithoutTitleButWithURL(t *testing.T) {
func TestParseItemWithoutTitleButWithURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -430,7 +575,7 @@ func TestParseFeedItemWithoutTitleButWithURL(t *testing.T) {
}
}
func TestParseFeedItemWithoutTitleButWithSummary(t *testing.T) {
func TestParseItemWithoutTitleButWithSummary(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -457,7 +602,7 @@ func TestParseFeedItemWithoutTitleButWithSummary(t *testing.T) {
}
}
func TestParseFeedItemWithoutTitleButWithHTMLContent(t *testing.T) {
func TestParseItemWithoutTitleButWithHTMLContent(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -484,7 +629,7 @@ func TestParseFeedItemWithoutTitleButWithHTMLContent(t *testing.T) {
}
}
func TestParseFeedItemWithoutTitleButWithTextContent(t *testing.T) {
func TestParseItemWithoutTitleButWithTextContent(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -515,7 +660,7 @@ func TestParseFeedItemWithoutTitleButWithTextContent(t *testing.T) {
}
}
func TestParseTruncateItemTitleUnicode(t *testing.T) {
func TestParseItemWithTooLongUnicodeTitle(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
@ -573,15 +718,34 @@ func TestParseItemTitleWithXMLTags(t *testing.T) {
}
}
func TestParseInvalidJSON(t *testing.T) {
data := `garbage`
_, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err == nil {
t.Error("Parse should returns an error")
func TestParseItemWithoutID(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"content_text": "Some text."
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "13b4c5aecd1b6d749afcee968fbf9c80f1ed1bbdbe1aaf25cb34ebd01144bbe9" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
}
func TestParseTags(t *testing.T) {
func TestParseItemTags(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
@ -600,7 +764,8 @@ func TestParseTags(t *testing.T) {
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
"date_published": "2016-02-09T14:22:00-07:00",
"tags": [
"tag 1",
" tag 1",
" ",
"tag 2"
]
}
@ -623,11 +788,11 @@ func TestParseTags(t *testing.T) {
}
}
func TestParseFavicon(t *testing.T) {
func TestParseFeedFavicon(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"favicon": "https://micro.blog/jsonfeed/favicon.png",
"favicon": "https://example.org/jsonfeed/favicon.png",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
@ -648,7 +813,81 @@ func TestParseFavicon(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if feed.IconURL != "https://micro.blog/jsonfeed/favicon.png" {
if feed.IconURL != "https://example.org/jsonfeed/favicon.png" {
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
}
}
func TestParseFeedIcon(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"icon": "https://example.org/jsonfeed/icon.png",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "2",
"content_text": "This is a second item.",
"url": "https://example.org/second-item"
},
{
"id": "1",
"content_html": "<p>Hello, world!</p>",
"url": "https://example.org/initial-post"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.IconURL != "https://example.org/jsonfeed/icon.png" {
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
}
}
func TestParseFeedWithRelativeAttachmentURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "My Example Feed",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "2",
"content_text": "This is a second item.",
"url": "https://example.org/second-item",
"attachments": [
{
"url": " /attachment.mp3 ",
"mime_type": "audio/mpeg",
"size_in_bytes": 123456
}
]
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries[0].Enclosures) != 1 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
if feed.Entries[0].Enclosures[0].URL != "https://example.org/attachment.mp3" {
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
}
}
func TestParseInvalidJSON(t *testing.T) {
data := `garbage`
_, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err == nil {
t.Error("Parse should returns an error")
}
}

View File

@ -11,17 +11,18 @@ import (
var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
// Element represents XML media elements.
type Element struct {
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []Thumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
MediaPeerLinks []PeerLink `xml:"http://search.yahoo.com/mrss/ peerLink"`
// Specs: https://www.rssboard.org/media-rss
type MediaItemElement struct {
MediaCategories MediaCategoryList `xml:"http://search.yahoo.com/mrss/ category"`
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
MediaThumbnails []Thumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
MediaPeerLinks []PeerLink `xml:"http://search.yahoo.com/mrss/ peerLink"`
}
// AllMediaThumbnails returns all thumbnail elements merged together.
func (e *Element) AllMediaThumbnails() []Thumbnail {
func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
var items []Thumbnail
items = append(items, e.MediaThumbnails...)
for _, mediaGroup := range e.MediaGroups {
@ -31,7 +32,7 @@ func (e *Element) AllMediaThumbnails() []Thumbnail {
}
// AllMediaContents returns all content elements merged together.
func (e *Element) AllMediaContents() []Content {
func (e *MediaItemElement) AllMediaContents() []Content {
var items []Content
items = append(items, e.MediaContents...)
for _, mediaGroup := range e.MediaGroups {
@ -41,7 +42,7 @@ func (e *Element) AllMediaContents() []Content {
}
// AllMediaPeerLinks returns all peer link elements merged together.
func (e *Element) AllMediaPeerLinks() []PeerLink {
func (e *MediaItemElement) AllMediaPeerLinks() []PeerLink {
var items []PeerLink
items = append(items, e.MediaPeerLinks...)
for _, mediaGroup := range e.MediaGroups {
@ -51,7 +52,7 @@ func (e *Element) AllMediaPeerLinks() []PeerLink {
}
// FirstMediaDescription returns the first description element.
func (e *Element) FirstMediaDescription() string {
func (e *MediaItemElement) FirstMediaDescription() string {
description := e.MediaDescriptions.First()
if description != "" {
return description
@ -85,17 +86,17 @@ type Content struct {
// MimeType returns the attachment mime type.
func (mc *Content) MimeType() string {
switch {
case mc.Type == "" && mc.Medium == "image":
return "image/*"
case mc.Type == "" && mc.Medium == "video":
return "video/*"
case mc.Type == "" && mc.Medium == "audio":
return "audio/*"
case mc.Type == "" && mc.Medium == "video":
return "video/*"
case mc.Type != "":
if mc.Type != "" {
return mc.Type
}
switch mc.Medium {
case "image":
return "image/*"
case "video":
return "video/*"
case "audio":
return "audio/*"
default:
return "application/octet-stream"
}
@ -103,9 +104,6 @@ func (mc *Content) MimeType() string {
// Size returns the attachment size.
func (mc *Content) Size() int64 {
if mc.FileSize == "" {
return 0
}
size, _ := strconv.ParseInt(mc.FileSize, 10, 0)
return size
}
@ -173,3 +171,20 @@ func (dl DescriptionList) First() string {
}
return ""
}
type MediaCategoryList []MediaCategory
func (mcl MediaCategoryList) Labels() []string {
var labels []string
for _, category := range mcl {
label := strings.TrimSpace(category.Label)
if label != "" {
labels = append(labels, label)
}
}
return labels
}
type MediaCategory struct {
Label string `xml:"label,attr"`
}

View File

@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
Title: feed.Title,
FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL,
Description: feed.Description,
CategoryName: feed.Category.Title,
})
}
@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
}
feed := &model.Feed{
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Category: category,
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
Category: category,
}
h.store.CreateFeed(feed)

View File

@ -27,11 +27,12 @@ type opmlHeader struct {
}
type opmlOutline struct {
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Description string `xml:"description,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
}
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {

View File

@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
Title: outline.GetTitle(),
FeedURL: outline.FeedURL,
SiteURL: outline.GetSiteURL(),
Description: outline.Description,
CategoryName: category,
})
} else if outline.Outlines.HasChildren() {

View File

@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
`
var expected SubcriptionList
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"})
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/", Description: "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media."})
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {

View File

@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
for _, subscription := range groupedSubs[categoryName] {
category.Outlines = append(category.Outlines, opmlOutline{
Title: subscription.Title,
Text: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Title: subscription.Title,
Text: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
})
}

View File

@ -9,12 +9,14 @@ type Subcription struct {
SiteURL string
FeedURL string
CategoryName string
Description string
}
// Equals compare two subscriptions.
func (s Subcription) Equals(subscription *Subcription) bool {
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
s.Description == subscription.Description
}
// SubcriptionList is a list of subscriptions.

View File

@ -21,12 +21,12 @@ const (
)
// DetectFeedFormat tries to guess the feed format from input data.
func DetectFeedFormat(r io.ReadSeeker) string {
func DetectFeedFormat(r io.ReadSeeker) (string, string) {
data := make([]byte, 512)
r.Read(data)
if bytes.HasPrefix(bytes.TrimSpace(data), []byte("{")) {
return FormatJSON
return FormatJSON, ""
}
r.Seek(0, io.SeekStart)
@ -41,14 +41,19 @@ func DetectFeedFormat(r io.ReadSeeker) string {
if element, ok := token.(xml.StartElement); ok {
switch element.Name.Local {
case "rss":
return FormatRSS
return FormatRSS, ""
case "feed":
return FormatAtom
for _, attr := range element.Attr {
if attr.Name.Local == "version" && attr.Value == "0.3" {
return FormatAtom, "0.3"
}
}
return FormatAtom, "1.0"
case "RDF":
return FormatRDF
return FormatRDF, ""
}
}
}
return FormatUnknown
return FormatUnknown, ""
}

View File

@ -10,7 +10,7 @@ import (
func TestDetectRDF(t *testing.T) {
data := `<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://my.netscape.com/rdf/simple/0.9/"></rdf:RDF>`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatRDF {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRDF)
@ -19,7 +19,7 @@ func TestDetectRDF(t *testing.T) {
func TestDetectRSS(t *testing.T) {
data := `<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatRSS {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRSS)
@ -28,7 +28,7 @@ func TestDetectRSS(t *testing.T) {
func TestDetectAtom10(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatAtom {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
@ -37,7 +37,7 @@ func TestDetectAtom10(t *testing.T) {
func TestDetectAtom03(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?><feed version="0.3" xmlns="http://purl.org/atom/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:lang="en"></feed>`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatAtom {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
@ -46,7 +46,7 @@ func TestDetectAtom03(t *testing.T) {
func TestDetectAtomWithISOCharset(t *testing.T) {
data := `<?xml version="1.0" encoding="ISO-8859-15"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatAtom {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
@ -60,7 +60,7 @@ func TestDetectJSON(t *testing.T) {
"title" : "Example"
}
`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatJSON {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)
@ -71,7 +71,7 @@ func TestDetectUnknown(t *testing.T) {
data := `
<!DOCTYPE html> <html> </html>
`
format := DetectFeedFormat(strings.NewReader(data))
format, _ := DetectFeedFormat(strings.NewReader(data))
if format != FormatUnknown {
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatUnknown)

View File

@ -19,10 +19,11 @@ var ErrFeedFormatNotDetected = errors.New("parser: unable to detect feed format"
// ParseFeed analyzes the input data and returns a normalized feed object.
func ParseFeed(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
r.Seek(0, io.SeekStart)
switch DetectFeedFormat(r) {
format, version := DetectFeedFormat(r)
switch format {
case FormatAtom:
r.Seek(0, io.SeekStart)
return atom.Parse(baseURL, r)
return atom.Parse(baseURL, r, version)
case FormatRSS:
r.Seek(0, io.SeekStart)
return rss.Parse(baseURL, r)

View File

@ -4,10 +4,31 @@
package parser // import "miniflux.app/v2/internal/reader/parser"
import (
"os"
"strings"
"testing"
)
func BenchmarkParse(b *testing.B) {
var testCases = map[string][]string{
"large_atom.xml": {"https://dustri.org/b", ""},
"large_rss.xml": {"https://dustri.org/b", ""},
"small_atom.xml": {"https://github.com/miniflux/v2/commits/main", ""},
}
for filename := range testCases {
data, err := os.ReadFile("./testdata/" + filename)
if err != nil {
b.Fatalf(`Unable to read file %q: %v`, filename, err)
}
testCases[filename][1] = string(data)
}
for range b.N {
for _, v := range testCases {
ParseFeed(v[0], strings.NewReader(v[1]))
}
}
}
func FuzzParse(f *testing.F) {
f.Add("https://z.org", `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
@ -64,7 +85,35 @@ func FuzzParse(f *testing.F) {
})
}
func TestParseAtom(t *testing.T) {
func TestParseAtom03Feed(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<title>dive into mark</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
<issued>2003-12-13T08:29:29-04:00</issued>
<modified>2003-12-13T18:30:02Z</modified>
<summary type="text/plain">It&apos;s a test</summary>
<content type="text/html" mode="escaped"><![CDATA[<p>HTML content</p>]]></content>
</entry>
</feed>`
feed, err := ParseFeed("https://example.org/", strings.NewReader(data))
if err != nil {
t.Error(err)
}
if feed.Title != "dive into mark" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom10Feed(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

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