Compare commits

..

177 Commits

Author SHA1 Message Date
Frédéric Guillot 600f19cc87 docs(changelog): update release notes for version 2.2.6 2025-02-22 16:25:31 -08:00
jvoisin 4a77e937af perf(sanitizer): remove two useless calls to strings.ReplaceAll
The [strings.Fields](https://pkg.go.dev/strings#Fields) considers `'\t', '\n',
'\v', '\f', '\r', ' ', U+0085 (NEL), U+00A0 (NBSP).` as spaces, so no need to
remove them beforehand.

This is a continuation of f2f60a8f73
2025-02-18 19:42:39 -08:00
dependabot[bot] f7e30822c3 build(deps): bump golang in /packaging/debian
Bumps golang from 1.23-bookworm to 1.24-bookworm.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-18 19:39:27 -08:00
Frédéric Guillot 1cffb70362 ci: trigger packaging tests on pull requests 2025-02-17 20:48:42 -08:00
Frédéric Guillot 462ba8d7f7 feat(sanitizer): allow img tags with only a srcset and no src attribute 2025-02-15 18:03:36 -08:00
Frédéric Guillot 991c54150d fix(css): avoid aside overflow on the pagination menu 2025-02-15 17:15:52 -08:00
Frédéric Guillot 6eedf4111f fix(scraper): avoid encoding issue if charset meta tag is after 1024 bytes 2025-02-15 17:05:14 -08:00
Frédéric Guillot af1f966250 test(encoding): add unit tests for CharsetReader function 2025-02-15 15:40:07 -08:00
Frédéric Guillot 7f54b27079 fix(rss): handle item title with CDATA content correctly
Fix regression introduced in commit a3ce03cc
2025-02-15 14:51:27 -08:00
Frédéric Guillot a3ce03cc9d feat(rss): add workaround for RSS item title with HTML content 2025-02-14 21:21:49 -08:00
Frédéric Guillot 502db64b28 ci: update GitHub Actions workflows to use Go 1.24 2025-02-13 21:29:58 -08:00
Frédéric Guillot 033806df4b refactor(locale): sort JSON documents by key 2025-02-13 21:25:26 -08:00
Frédéric Guillot 50bd6eb5d9 feat(locale): update French translation 2025-02-13 21:15:29 -08:00
Sergio Moura 3387201634 feat(pushover): add integration with pushover.net 2025-02-13 20:50:37 -08:00
dependabot[bot] 76acd81fa4 build(deps): bump github.com/PuerkitoBio/goquery from 1.10.1 to 1.10.2
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.1 to 1.10.2.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.1...v1.10.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>
2025-02-13 17:30:22 -08:00
krvpb024 28b235595e feat(locale): add Taiwanese POJ (nan-Latn-pehoeji) support 2025-02-13 17:29:13 -08:00
dependabot[bot] 9398f7abd7 build(deps): bump golang.org/x/net from 0.34.0 to 0.35.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/net/compare/v0.34.0...v0.35.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>
2025-02-10 18:01:37 -08:00
dependabot[bot] 0f55c81e2b build(deps): bump golang.org/x/crypto from 0.32.0 to 0.33.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/crypto/compare/v0.32.0...v0.33.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>
2025-02-07 16:21:33 -08:00
Frédéric Guillot f2f60a8f73 feat(sanitizer): improve text truncation with better space handling 2025-02-06 21:21:49 -08:00
Frédéric Guillot e777f12490 fix(sanitizer): correct HTML tag name from tfooter to tfoot 2025-02-06 21:16:29 -08:00
Frédéric Guillot 7bdf133b9d ci: add commitlint to validate PR commit messages 2025-02-06 20:11:56 -08:00
Julien Voisin 7eb1d15315 refactor(date): use an else-if instead of two if statements 2025-02-06 19:44:12 -08:00
Cthulhux a74186c555 fix(locale): missing hyphen in de_DE.json 2025-02-05 20:13:08 -08:00
dependabot[bot] fb2fdf17b7 build(deps): bump golang.org/x/image from 0.23.0 to 0.24.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/image/compare/v0.23.0...v0.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-04 19:08:24 -08:00
dependabot[bot] 6edb3da977 build(deps): bump golang.org/x/term from 0.28.0 to 0.29.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/term/compare/v0.28.0...v0.29.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>
2025-02-04 17:43:43 -08:00
dependabot[bot] c0309b6ad1 build(deps): bump golang.org/x/oauth2 from 0.25.0 to 0.26.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.25.0...v0.26.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>
2025-02-04 17:33:18 -08:00
MDeLuise 7bf1dd6e7a fix: update Linkace integration to support API v2 2025-02-02 12:40:02 -08:00
Frédéric Guillot 6a008eee14 feat(ui): open the <details> tag in edit feed page when the feature is enabled 2025-01-31 17:21:49 -08:00
Matthaiks b9b2d6822a feat(locale): update Polish translation 2025-01-31 17:18:48 -08:00
Wesley van Tilburg 459284ab96 feat(integration): add webhook URL per feed 2025-01-31 16:33:11 -08:00
Julien Voisin b193bc212a refactor(xml): improve the performances of NewXMLDecoder
- Invert a condition to make the code more readable
- Extract the encoding directly from the slice of bytes instead of converting
  it to string first.
2025-01-30 19:37:06 -08:00
Frédéric Guillot 3ebeb38ade fix(api): return 500 response when JSON serialization fails 2025-01-30 18:19:50 -08:00
Frédéric Guillot c951ac2876 fix: json encoding is failing with dates at OAD and negative timezone offset 2025-01-30 18:19:50 -08:00
Julien Voisin 7275bc808a feat(urlcleaner): add trackers to the blocklist 2025-01-29 19:32:19 -08:00
Sangeeth Sudheer e40446ad3c fix(ui): Redirect correctly post feed removal from category feeds list
Currently, removing a feed from `/category/{id}/feeds` redirects incorrectly to `/feeds`. This change fixes it so that
removing a feed will now correctly redirect to `/category/{id}/feeds`. Removing a feed from `/feeds` is unaffected and
will work as it does currently.

To fix this, a new UI endpoint `/category/{categoryID}/feed/{feedID}/remove` is added and a corresponding handler method
to validate and perform the removal from DB.
2025-01-27 17:43:54 -08:00
CaptainArk fba23cf464 feat(integration): add Slack integration 2025-01-27 17:37:22 -08:00
Mikael Berthe bae872e79b feat(locale): update French translations
Fix some French translations.
2025-01-26 10:38:31 -08:00
Peter Dave Hello d0c9ef5acf chore(i18n): update and improve zh_TW Traditional Chinese translations 2025-01-25 14:56:14 -08:00
Frédéric Guillot 0c74497ef7 fix(js): regression introduced in commit ffe1be5
The default argument should be set to false.
2025-01-24 17:52:58 -08:00
Frédéric Guillot 369054b02d feat(processor): fetch YouTube watch time in bulk using the API 2025-01-24 15:16:23 -08:00
Frédéric Guillot c3c42b0c37 fix(scraper): update TechCrunch scraper rule 2025-01-23 19:29:32 -08:00
jvoisin 4938a94968 Remove another superfluous cast 2025-01-23 19:20:13 -08:00
jvoisin ffe1be59ea Use a default parameter for goToPage as isn't ~always called with a single one 2025-01-23 19:20:13 -08:00
jvoisin 2318e9011d Use proper types in app.js 2025-01-23 19:20:13 -08:00
jvoisin 67df305ac2 Use shortcuts to declare padding 2025-01-23 19:20:13 -08:00
jvoisin 2e57e3351b Remove superfluous parenthesis 2025-01-23 19:20:13 -08:00
jvoisin a412cde3b3 Don't define receivers on both values and pointer
And use `o` instead of `outline` as done everywhere else.
2025-01-23 19:20:13 -08:00
jvoisin abfd9306a4 Guard against a potential null dereference 2025-01-23 19:20:13 -08:00
jvoisin 8889e44b5d Simplify a condition 2025-01-23 19:20:13 -08:00
jvoisin 9657bf1f5b Don't define methods both on instance and pointer
See https://go.dev/tour/methods/8
2025-01-23 19:20:13 -08:00
jvoisin 8c5f88ac62 Remove superfluous parenthesis 2025-01-23 19:20:13 -08:00
jvoisin 736f8b4dac Don't use defer in a loop
As the body of request isn't used, we can sloe it immediately.
2025-01-23 19:20:13 -08:00
jvoisin 60e1d9e361 Broaden an error condition
`http.ErrNoCookie` isn't the only possible error value.
2025-01-23 19:20:13 -08:00
jvoisin 7967ce4df2 Remove a useless cast 2025-01-23 19:20:13 -08:00
jvoisin 71c7845c42 Anchor = removal in webauthn_handler.js
Since we're base64-encoding, `=` can only happen at the end, so no need to
traverse the whole payload.
2025-01-23 19:20:13 -08:00
jvoisin aa56d23551 Replace the deprecated window.pageYOffset with window.scollY
See https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY
2025-01-23 19:20:13 -08:00
jvoisin 3cd448099d Remove a useless return 2025-01-23 19:20:13 -08:00
Frédéric Guillot 47ccefba4e fix(css): --entry-content-aside-border-color is missing from
`system.css`
2025-01-22 21:00:50 -08:00
Frédéric Guillot 1faccc7eca fix(sanitizer): non-allowed attributes are not properly stripped
Regression introduced in commit 58178d90cb
2025-01-22 20:50:38 -08:00
Frédéric Guillot e74d875d95 feat(css): improve aside element position on smartphone 2025-01-22 20:24:33 -08:00
Frédéric Guillot 33063a7775 docs(changelog): update release notes for version 2.2.5 2025-01-20 11:12:46 -08:00
Frédéric Guillot 49c62db2e1 fix: update Wallabag URL label to avoid confusion 2025-01-18 17:22:49 -08:00
Frédéric Guillot 400e8974f9 fix: improve pagination when having identical publication date 2025-01-18 16:59:48 -08:00
Frédéric Guillot 9c82e55b98 fix: do not strip tags in Atom entry title 2025-01-18 15:33:44 -08:00
Frédéric Guillot c9c422b135 feat: bump linter and minifier from ECMAScript 2017 to 2020 (ES11) 2025-01-18 11:32:50 -08:00
Julien Voisin 91f9a7650e refactor(js): add jshint check for strict comparison 2025-01-16 17:50:09 -08:00
jvoisin 605eeb4525 Fix a mistake introduced in f67d2e230b
Spotted by @michaelkuhn
2025-01-16 17:37:54 -08:00
Julien Voisin eb6991ae49 tests(js): improve .jshintrc 2025-01-15 18:43:03 -08:00
Cthulhux eac5d59f5b feat(locale): update German translation
New ntfy string translated
2025-01-15 17:15:55 -08:00
dependabot[bot] 3861f1aa67 build(deps): bump github.com/tdewolff/minify/v2 from 2.21.2 to 2.21.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.21.2 to 2.21.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.21.2...v2.21.3)

---
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>
2025-01-14 20:50:56 -08:00
Julien Voisin 6af0cd5b5c refactor(js): simplify a bit keyboard_handler.js
- Mark two methods as static
- Use a `switch-case` construct instead of an Object and a loop.
2025-01-14 20:50:21 -08:00
Julien Voisin fccca0ce1e refactor(js): minor refactoring of touch_handler.js
- Mark a method as `static`
- use `Math.sqrt` instead of `Math.pow(…, 0.5)`
- Use `Math.sign` instead of a condition on the sign
- Inline some used-once variables
- Reduce the scope of some variables
2025-01-14 20:47:30 -08:00
Julien Voisin 8c3a9184ac refactor(js): remove an outdated check for {passive: true}
According to https://caniuse.com/?search=passive,
all browsers released after 2016 do support passive event listeners,
so no need to check for its presence.
2025-01-14 20:18:11 -08:00
Matthaiks 3b317b0b80 feat(locale): update Polish translation 2025-01-13 18:38:22 -08:00
Brieuc Dubois a702bf0342 feat(ntfy): Add option to use internal links 2025-01-13 10:36:49 -08:00
Frédéric Guillot e9520f5d1c fix(finder): do not add redirections to the list of subscriptions to avoid confusion 2025-01-12 17:09:32 -08:00
Frédéric Guillot f5fde36d45 fix(ui): reading preferences are reset if the form values are incorrect 2025-01-12 16:16:29 -08:00
Jake Walker 6cbe8c3a9d feat: add fix_ghost_cards rewrite rule 2025-01-12 14:43:27 -08:00
Julien Voisin 1e54a073d3 refactor(js): minor improvements in app.js
- Use `….classList.toggle` instead of `….classList.add`/`….classList.remove` in a condition
- Replace a `function()` with a `() =>`
- Use `Math.min` instead of a handwritten condition
2025-01-12 12:54:08 -08:00
dependabot[bot] bf66ef5a0f build(deps): bump github.com/coreos/go-oidc/v3 from 3.11.0 to 3.12.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.11.0 to 3.12.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.11.0...v3.12.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>
2025-01-12 12:22:16 -08:00
CaptainArk 9b25ea4ed6 feat(integration): add Discord integration 2025-01-12 12:18:57 -08:00
Julien Voisin f116f7dd6a test(sanitizer): add a fuzzer 2025-01-11 17:19:31 -08:00
dependabot[bot] e540547104 build(deps): bump golang.org/x/oauth2 from 0.24.0 to 0.25.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.24.0...v0.25.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>
2025-01-11 17:12:57 -08:00
dependabot[bot] 5b1abbf340 build(deps): bump golang.org/x/net from 0.33.0 to 0.34.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.34.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>
2025-01-11 17:05:27 -08:00
dependabot[bot] 0d72cef82e build(deps): bump golang.org/x/crypto from 0.31.0 to 0.32.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.32.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>
2025-01-11 16:51:21 -08:00
Julien Voisin 79ec6ef81f feat(database): add optional build support for SQLite
Miniflux can be build with `go build -tags=sqlite` to test this. Note that
while it builds, it will fail at runtime, as some of the SQL used in miniflux is
postgresql-specific.
2024-12-29 15:09:26 -08:00
jvoisin 8d4954e29b Return an error should it happen in migrations 2024-12-29 11:51:47 -08:00
dependabot[bot] c81e17c20d build(deps): bump github.com/PuerkitoBio/goquery from 1.10.0 to 1.10.1
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.0 to 1.10.1.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.0...v1.10.1)

---
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-12-27 15:08:35 -08:00
Frédéric Guillot 5549f75dd7 fix(sanitizer): allow <hr> tags 2024-12-27 13:56:06 -08:00
Frédéric Guillot fc3c4873e5 doc: add note regarding validateUsername 2024-12-26 15:05:15 -08:00
Frédéric Guillot b87c547a07 ci: run Docker tests only when the Dockerfiles are modified 2024-12-26 14:58:32 -08:00
Frédéric Guillot 3466e9e2d6 ci: avoid building Linux packages for each pull-request 2024-12-26 14:57:52 -08:00
Julien Voisin 8df4b780a8 refactor(readingtime): replace whatlanggo with an ad-hoc implementation
The package `github.com/abadojack/whatlanggo` is unmaintained since 5 years, is
overkill for simply detecting CJK, and is quite slow.
2024-12-26 14:21:07 -08:00
Julien Voisin e22520fc55 feat: validate usernames upon creation
The validation doesn't apply to already created usernames.

This should close #925
2024-12-26 14:14:07 -08:00
Julien Voisin 518bc4d6ff refactor(database): add special handling for PostgreSQL-specific migrations 2024-12-26 14:09:37 -08:00
Julien Voisin 89620a7dd2 refactor(oauth2): no need to use io.WriteString when sha256 provides a way to obtain a sum in a single call 2024-12-26 10:39:55 -08:00
Julien Voisin bbfe39722a ci: tighten the CodeQL rules
- don't run CodeQL on test files
- don't run CodeQL if no `.go` nor `.js` file have been modified.
2024-12-26 10:33:41 -08:00
Julien Voisin f3989cdb2f ci: checkout before installing Go
Obtaining the code before deploying go allows better caching, as the go.sum
file becomes available.

See https://github.com/actions/setup-go/issues/281
2024-12-23 21:24:22 -08:00
Julien Voisin 195b75d185 refactor(rewriter): use custom title case converter implementation instead of golang.org/x/text/cases.Title()
The implementation is equivalent to
`cases.Title(language.English).String(strings.ToLower(…))`,
and this is the only place in miniflux where
"golang.org/x/text/cases" and "golang.org/x/text/language"
are (directly) used.

This reduces the binary size from 27015590 to
26686112 on my machine.

Kudos to https://gsa.zxilly.dev for making it straightforward to catch things
like this.
2024-12-23 21:16:02 -08:00
Julien Voisin f52411f734 ci: only run -race -cover on Ubuntu
The coverage information isn't used anywhere in the CI, so no need to have it
for every OS. As for `-race`, there is no point in using it everywhere, one
time should be enough, especially since it's taking a lot of time on Windows.
2024-12-23 11:45:45 -08:00
Julien Voisin b93543f416 feat: replace %{?systemd_requires} with %{?systemd_ordering}
As said [in the documentation](https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#_dependencies_on_the_systemd_package):

> If the package wants to use systemd tools if they are available, but does not
want to declare a dependency, then the `%{?systemd_ordering}` macro MAY be used
as a weaker form of %{?systemd_requires} that only declares an ordering during
an RPM transaction.

See https://github.com/systemd/systemd/commit/2424b6bd716f0c1c3bf3406b1fd1a16ba1b6a556
and https://pagure.io/packaging-committee/issue/644 for more information.

And also use `--setopt=install_weak_deps=False` to avoid installing a lot of
useless dependencies.
2024-12-23 11:34:29 -08:00
Julien Voisin 057f760196 ci: don't run go vet ./... as it's run as part of golangci-lint
See https://golangci-lint.run/usage/linters/#govet
2024-12-22 22:04:00 -08:00
Julien Voisin 28fe053329 ci: don't specify languages for CodeQL
As stated in the [documentation](https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning#changing-the-languages-that-are-analyzed):

> CodeQL code scanning automatically detects code written in the supported languages.

This will also reduce the number of CodeQL jobs from two to one.

See #3029
2024-12-22 22:00:06 -08:00
Frédéric Guillot d345c87376 docs(changelog): update release notes for version 2.2.4 2024-12-20 13:05:52 -08:00
jvoisin bd91e5f320 Add more referer spoofing
Based on #2261. For moyu.im/jandan.net, see https://github.com/DIYgod/RSSHub/issues/11528
2024-12-20 11:53:38 -08:00
dependabot[bot] 276b2d8b0b build(deps): bump golang.org/x/net from 0.32.0 to 0.33.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.32.0...v0.33.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-12-18 17:41:28 -08:00
Sevi.C bca9bea676 feat: add date-based entry filtering rules 2024-12-16 20:38:20 -08:00
dependabot[bot] 7346d751cc build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.20 to 3.21.

---
updated-dependencies:
- dependency-name: library/alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 19:35:31 -08:00
jvoisin 7939b54341 Resize favicons to 32x32 to account of scaling
As suggested by @michaelkuhn in https://github.com/miniflux/v2/pull/2998#issuecomment-2546702212
2024-12-16 19:28:38 -08:00
jvoisin a06657b74d Factorise a line in internal/ui/static/js/app.js 2024-12-15 20:54:17 -08:00
jvoisin 2df59b4865 Refactor internal/reader/readability/testdata
- Use chained strings.Contains instead of a regex for
  blacklistCandidatesRegexp, as this is a bit faster
- Simplify a Find.Each.Remove to Find.Remove
- Don't concatenate id and class for removeUnlikelyCandidates, as it makes no
  sense to match on overlaps. It might also marginally improve performances, as
  regex now have to run on two strings separately, instead of both.
- Add a small benchmark
2024-12-15 20:52:32 -08:00
Julien Voisin 777d0dd248 feat: resize favicons before storing them
Some websites are using images of O(10kB) when not )O(100kB) for their
favicons. As miniflux only displays them with a 16x16 resolution, let's do our
best to resize them before storing them in the database. This should make
miniflux consume less bandwidth when serving pages, for the joy of mobile users
on a small data plan.

Of course, images that already are 16x16 aren't resized.
2024-12-15 20:47:19 -08:00
Julien Voisin cfda948c3a refactor(rewriter): avoid the use of regex in addDynamicImage
See https://dustri.org/b/parsing-noscript-tags-with-goquery.html for the whole
story.
2024-12-15 17:56:39 -08:00
jvoisin 14a6e8ed3a Factorise .pagination-next and .pagination-last together 2024-12-15 17:03:09 -08:00
jvoisin c3e842eba6 Remove -webkit-clip-path
https://caniuse.com/?search=clip-path says that `clip-path`
is supported since Safari 13.1
2024-12-15 17:03:09 -08:00
jvoisin fd9cfd757a Replace -ms-text-size-adjust with text-size-adjust
https://caniuse.com/?search=text-size-adjust says that
`ms-text-size-adjust` is supported in Edge.
2024-12-15 17:03:09 -08:00
Julien Voisin 945d436055 refactor(rewriter): replace regex with URL parsing for referrer override
No need for brittle regex when matching plain strings or domain names.
This should save some negligible amount of heap memory as well as
tremendously speeding up the matching.
2024-12-13 14:50:12 -08:00
Frédéric Guillot c3649bd6b1 refactor(rewrite): remove unused function arguments 2024-12-12 21:10:35 -08:00
Julien Voisin 6ad5ad0bb2 refactor(readability): various improvements and optimizations
- Replace a completely overkill regex
- Use `.Remove()` instead of a hand-rolled loop
- Use a strings.Builder instead of a bytes.NewBufferString
- Replace a call to Fprintf with string concatenation, as the latter are much
  faster
- Remove a superfluous cast
- Delay some computations
- Add some tests
2024-12-12 20:41:56 -08:00
Frédéric Guillot 113abeea59 test(rewrite): add unit test for referer rewrite function 2024-12-12 20:11:47 -08:00
Julien Voisin e6185b1393 refactor: use min/max instead of math.Min/math.Max
This saves a couple of back'n'forth casts.
2024-12-11 19:43:14 -08:00
Julien Voisin 1b0b8b9c42 refactor: use a better construct than doc.Find(…).First()
As mentioned in goquery's documentation (https://pkg.go.dev/github.com/PuerkitoBio/goquery#Single):

> By default, Selection.Find and other functions that accept a selector string
to select nodes will use all matches corresponding to that selector. By using
the Matcher returned by Single, at most the first match will be selected.
>
> The one using Single is optimized to be potentially much faster on large documents.
2024-12-11 19:40:55 -08:00
dependabot[bot] 68448b4abb build(deps): bump golang.org/x/crypto from 0.30.0 to 0.31.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.30.0...v0.31.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-12-11 19:31:26 -08:00
Julien Voisin 3caa16ac31 refactor(processor): use URL parsing instead of a regex 2024-12-11 19:30:59 -08:00
Julien Voisin 637fb85de0 refactor(handler): delay store.UserByID as much as possible
In internal/reader/handler/handler.go:RefreshFeed, there is a call to
store.UserByID pretty early, which is only used for
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language)
Its only other usage is in processor.ProcessFeedEntries(store, originalFeed,
user, forceRefresh), which is pretty late in RefreshFeed, and only called if
there are new items in the feed. It makes sense to only fetch the user's
language if the error localization function is used.

Calls to `store.UserByID` take around 10% of the CPU time of RefreshFeed in my
profiling.

This commit also makes `processor.ProcessFeedEntries` take a `userID` instead
of a `user`, to make the code a bit more concise.

This should close #2984
2024-12-09 19:32:59 -08:00
Julien Voisin 02c6d14659 refactor(subscription): use strings.HasSuffix instead of a regex in FindSubscriptionsFromYouTubePlaylistPage 2024-12-09 17:19:28 -08:00
Julien Voisin 728423339a refactor(sanitizer): improve rewriteIframeURL()
- Use `url.Parse` instead of a regex, as this is much faster and way more robust
- Add support for Vimeo's Do Not Track parameter
2024-12-09 17:14:54 -08:00
Julien Voisin eed3fcf92a refactor(locale): delay parsing of translations until they're used
While doing some profiling for #2900, I noticed that
`miniflux.app/v2/internal/locale.LoadCatalogMessages` is responsible for more
than 10% of the consumed memory. As most miniflux instances won't have enough
diverse users to use all the available translations at the same time, it
makes sense to load them on demand.

The overhead is a single function call and a check in a map, per call to
translation-related functions.
2024-12-09 17:05:14 -08:00
Qeynos d5cfcf8956 feat(locale): update Chinese translation 2024-12-08 17:06:29 -08:00
Julien Voisin dea46ac0ea refactor: optimize sanitizeAttributes
- Use string concatenation instead of `Sprintf`, as this is much faster, and the
  call to `Sprintf` is responsible for 30% of the CPU time of the function
- Anchor the youtube regex, to allow it to bail early, as this also account for
  another 30% of the CPU time. It might be worth chaining calls to `TrimPrefix`
  and check if the string has been trimmed instead of using a regex, to speed
  things up even more, but this needs to be benchmarked properly.
2024-12-08 14:42:18 -08:00
Julien Voisin 30c44380e0 refactor: get rid of numberOfPluralFormsPerLanguage test-only variable
The `numberOfPluralFormsPerLanguage` variable is only used for tests, so it
should be declared in the test file.
2024-12-08 14:39:11 -08:00
Julien Voisin a913f3f75f feat(rewrite)!: remove parse_markdown rewrite rule
It was added in 2022 by #1513, to support blog.laravel.com, which has
since switched to HTML. The Atom 0.3/1.0, RSS 1.0/2.0, RDF, and JSON formats
don't support markdown in their spec, and any website serving it there should
be considered as buggy and fixed.

This shaves off 2MB from the miniflux binary, which is quite steep for a
feature that nobody is/should be using, and remove a dependency which is always
a good thing.
2024-12-08 14:34:47 -08:00
AiraNadih c1ef986cab fix(consistency): align feed modification behavior between API and UI 2024-12-08 10:49:14 -08:00
Julien Voisin 2671f57edd refactor(readability): simplify the regexes in internal/reader/readability/readability.go
- Use strings.ToLower() instead of having case-insensitive regex
- Remove overlapping words in the regex
- Split a condition to increase readability
2024-12-07 16:56:19 -08:00
jvoisin 2f56ebd3a6 Remove a now-useless function 2024-12-07 16:50:18 -08:00
jvoisin 059f5c0905 Inline a condition 2024-12-07 16:50:18 -08:00
jvoisin 58178d90cb Refactor Sanitize
- Use `token.String()` instead of `html.EscapeString(token.Data)`
- Refactor conditions to highlight their similitude, enabling further
  refactoring

This refactoring brings forth at least one bug: `tagStack` is never emptied.
2024-12-07 16:50:18 -08:00
jvoisin cc885bbabb config.Opts is guaranteed to never be nil 2024-12-07 16:50:18 -08:00
jvoisin 0e185849b4 Google+ isn't a thing anymore 2024-12-07 16:50:18 -08:00
jvoisin d0984f29da Simplify isValidTag 2024-12-07 16:50:18 -08:00
jvoisin 902ca63c45 Inline a function and fix a bug in it
The `isAnchor` function's first parameter was always `a`, instead of being
passed `tagName`. As this function is a single line and was only called in a
single place, it can be inlined.
2024-12-07 16:50:18 -08:00
jvoisin 2314500515 Merge two conditions 2024-12-07 16:50:18 -08:00
jvoisin 787d373211 Change the scope of a variable 2024-12-07 16:50:18 -08:00
Julien Voisin fefbf2c935 refactor(processor): improve the rewrite URL rule regex
- Use `[^"]` instead of `.`, to help the regex engine to determine boundaries,
  instead of having it bruteforce its way to find them
- Use `+` instead of `*`, as empty rules don't make sense
2024-12-07 16:35:51 -08:00
Julien Voisin bfb429b919 refactor(sanitizer): optimize internal/reader/sanitizer/strip_tags.go
- Use strings instead of doing string->bytes->string
- Use a strings.Builder to build the output
2024-12-07 16:31:48 -08:00
Julien Voisin 331c831c23 refactor(sanitizer): simplify hasRequiredAttributes
This function takes around 1.5% of the total CPU time on my instance, and most
of it is spent in `mapassign_faststr` to initialize the `map`. This is replaced
with a switch-case construct, that should be both significantly faster as well
as pretty dull in term of memory consumption.
2024-12-07 16:30:15 -08:00
Julien Voisin 92a49d7e69 refactor(sanitizer): micro-optimizations of internal/reader/sanitizer/srcset.go
- Pre-allocate a slice
- Inline a local variable
- Remove a superfluous call to `strings.TrimSpace`
- Simplify some conditions via a switch-case construct
2024-12-07 16:27:56 -08:00
mrchi 8cdf76df69 fix(linting): remove unnecessary blank line in PushEntries function 2024-12-07 16:19:53 -08:00
mrchi 7bc0bffd85 feat(apprise): update SendNotification to handle multiple entries and add logging 2024-12-07 16:19:53 -08:00
mrchi 3a18e5d205 feat(apprise): add title in notification request body 2024-12-07 16:19:53 -08:00
Gabe Cook c3ca603960 fix: load icon from site URL instead of feed URL 2024-12-07 16:06:26 -08:00
telnet23 7e2b50efee feat: optionally fetch watch time from YouTube API instead of website 2024-12-07 16:00:35 -08:00
Gabe Cook b61ee15c1b fix: feed icon from xml ignored during force refresh 2024-12-07 15:59:49 -08:00
Cthulhux b7b0ccbf4b feat(locale): update German translation to use Readeck URL 2024-12-06 15:13:21 -08:00
dependabot[bot] 9c5228b2ee build(deps): bump golang.org/x/net from 0.31.0 to 0.32.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/net/compare/v0.31.0...v0.32.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-12-04 18:29:21 -08:00
dependabot[bot] 53beec685b build(deps): bump golang.org/x/term from 0.26.0 to 0.27.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/term/compare/v0.26.0...v0.27.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-12-04 16:32:39 -08:00
Mohit Raj b1fb8be185 feat(locale): update translations to clarify readeck url instead of readeck api endpoint 2024-12-03 04:50:34 -08:00
Anshul Gupta 3a966c6ce5 fix: replace timezone function call with view
The `pg_timezone_names` view was added in 8.2.
It should be equivalent to the function query.
See: https://pgpedia.info/p/pg_timezone_names.html

This small change allows `miniflux` to run on postgres-compatible
databases like CockroachDB, which don't have this function.
2024-12-03 01:44:48 -08:00
dependabot[bot] b2e702218d build(deps): bump github.com/tdewolff/minify/v2 from 2.21.1 to 2.21.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.21.1 to 2.21.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.21.1...v2.21.2)

---
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-12-03 01:33:05 -08:00
Gabe Cook 30c2e09a56 chore: remove blog.laravel.com rewrite rule 2024-12-03 01:21:42 -08:00
3zero2 c6c71c58b8 feat: add predefined scraper rules for arstechnica.com 2024-11-14 17:47:31 -08:00
Julien Voisin 6eb1f25a53 feat: only show the commit URL if it's not empty on /about 2024-11-12 20:09:48 -08:00
AiraNadih f0fe91172f feat(mediaProxy): update predefined referer spoofing rules for restricted media resources 2024-11-12 19:47:23 -08:00
Cthulhux c3016a4c55 fix: fix grammar in pull-request template 2024-11-11 19:55:01 -08:00
Cthulhux 3a028a0669 feat(locale): update German translations 2024-11-11 19:53:58 -08:00
Frédéric Guillot da951164d5 docs(changelog): update release notes for version 2.2.3 2024-11-10 15:53:03 -08:00
dependabot[bot] a0988f6c16 build(deps): bump golang.org/x/net from 0.30.0 to 0.31.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/net/compare/v0.30.0...v0.31.0)

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-07 21:50:20 -08:00
Frédéric Guillot 65ff328804 fix: show only one player when there are several audio/video enclosures 2024-11-03 21:28:26 -08:00
John Tiesselune 7759ea1b43 feat(integration-shiori): updated shiori API to new endpoint for login/bookmark 2024-11-02 20:19:14 -07:00
Frédéric Guillot 7c5d6cf35f feat(mediaproxy): pass original filename in Content-Disposition header
When you download/save proxified media, the original filename is lost. That
information could be retained by passing a header `Content-Disposition: inline;
filename="ORIGNAL_FILENAME.EXT"` when serving the media file. The requested URL
would still be obfuscated, but if the client downloads the file it'll use that
original filename.
2024-10-30 20:44:19 -07:00
Frédéric Guillot 39cc1887ea fix: unable to change password due to a typo in SQL parameter 2024-10-30 19:58:37 -07:00
AiraNadih b0a3b4d5d9 style(mediaProxy): format with gofmt to pass linter checks 2024-10-30 19:50:12 -07:00
AiraNadih 469f23968e feat(mediaProxy): implement referer spoofing for restricted media resources 2024-10-30 19:50:12 -07:00
171 changed files with 14557 additions and 10907 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
Do you follow the guidelines?
Have you followed these 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 follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
- [ ] There are no breaking changes
- [ ] I have thoroughly tested my changes and verified there are no regressions
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
+3 -3
View File
@@ -9,13 +9,13 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
go-version: "1.24.x"
check-latest: true
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries
env:
CGO_ENABLED: 0
+9 -3
View File
@@ -5,9 +5,17 @@ permissions: read-all
on:
push:
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
schedule:
- cron: '45 22 * * 3'
@@ -22,8 +30,6 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
@@ -31,7 +37,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
go-version: "1.24.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
+6 -2
View File
@@ -5,11 +5,15 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
branches: [ main ]
paths:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
jobs:
test-packages:
if: github.event.pull_request
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -30,7 +34,7 @@ jobs:
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
+2
View File
@@ -7,6 +7,8 @@ on:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- 'packaging/docker/**'
jobs:
docker-images:
name: Docker Images
+20 -3
View File
@@ -28,8 +28,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- run: "go vet ./..."
go-version: "1.24.x"
- uses: golangci/golangci-lint-action@v6
with:
args: >
@@ -39,5 +38,23 @@ jobs:
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2024.1.1"
version: "latest"
install-go: false
commitlint:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install commitlint
run: |
npm install --save-dev @commitlint/config-conventional @commitlint/cli
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
- name: Validate PR commits
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
+6 -2
View File
@@ -5,11 +5,15 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
jobs:
test-package:
if: github.event.pull_request
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -21,7 +25,7 @@ jobs:
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
+11 -7
View File
@@ -15,16 +15,20 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.23.x"]
go-version: ["1.24.x"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Checkout
uses: actions/checkout@v4
- name: Run unit tests
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
- name: Run unit tests without coverage and race conditions checking
if: matrix.os != 'ubuntu-latest'
run: go test ./...
integration-tests:
name: Integration Tests
@@ -40,12 +44,12 @@ jobs:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- name: Checkout
uses: actions/checkout@v4
go-version: "1.24.x"
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
+178
View File
@@ -1,3 +1,181 @@
Version 2.2.6 (February 22, 2025)
---------------------------------
* test(encoding): add unit tests for `CharsetReader` function
* refactor(xml): improve the performances of `NewXMLDecoder`
* refactor(ui): remove superfluous cast
* refactor(request): broaden an error condition when parsing cookies
* refactor(processor): remove superfluous parenthesis
* refactor(opml): don't define receivers on both values and pointer
* refactor(model): simplify a condition
* refactor(model): don't define methods both on instance and pointer
* refactor(locale): sort JSON documents alphabetically by keys
* refactor(locale): remove superfluous parenthesis
* refactor(js): use proper types in `app.js`
* refactor(js): replace the deprecated `window.pageYOffset` with `window.scollY`
* refactor(js): remove a useless `return`
* refactor(js): anchor `=` removal in `webauthn_handler.js` regex
* refactor(js): add default value for parameter `fallbackSelf` in `goToPage` function
* refactor(integration): don't use `defer` in a loop
* refactor(icon): guard against a potential `null` dereference
* refactor(date): use an else-if instead of two if statements
* refactor(css): use shortcuts to declare padding
* refactor(client): remove a useless cast
* perf(sanitizer): remove two useless calls to `strings.ReplaceAll`
* fix(ui): Redirect correctly post feed removal from category feeds list
* fix(scraper): update TechCrunch scraper rule
* fix(scraper): avoid encoding issue if charset meta tag is after 1024 bytes
* fix(sanitizer): non-allowed attributes are not properly stripped
* fix(sanitizer): correct HTML tag name from `tfooter` to `tfoot`
* fix(rss): handle item title with `CDATA` content correctly
* fix(locale): missing hyphen in `de_DE.json`
* fix(css): avoid aside overflow on the pagination menu
* fix(css): `--entry-content-aside-border-color` is missing from `system.css`
* fix(api): return 500 response when JSON serialization fails
* fix(api): JSON encoding is failing with dates at OAD and negative timezone offset
* feat(urlcleaner): add trackers to the blocklist
* feat(ui): open the `<details>` tag in edit feed page when the feature is enabled
* feat(sanitizer): improve text truncation with better space handling
* feat(sanitizer): allow `img` tags with only a `srcset` and no `src` attribute
* feat(rss): add workaround for RSS item title with HTML content
* feat(pushover): add integration with pushover.net
* feat(processor): fetch YouTube watch time in bulk using the API
* feat(locale): update Traditional Chinese translation
* feat(locale): update Polish translation
* feat(locale): update French translation
* feat(locale): add Taiwanese POJ (nan-Latn-pehoeji)
* feat(integration): update Linkace integration to support API v2
* feat(integration): add webhook URL per feed
* feat(integration): add Slack integration
* feat(css): improve aside element position on smartphone
* ci: update GitHub Actions workflows to use Go 1.24
* ci: trigger packaging tests on pull requests
* ci: add `commitlint` to validate PR commit messages
* build(deps): bump `golang` in `/packaging/debian`
* build(deps): bump `golang.org/x/term` from `0.28.0` to `0.29.0`
* build(deps): bump `golang.org/x/oauth2` from `0.25.0` to `0.26.0`
* build(deps): bump `golang.org/x/net` from `0.34.0` to `0.35.0`
* build(deps): bump `golang.org/x/image` from `0.23.0` to `0.24.0`
* build(deps): bump `golang.org/x/crypto` from `0.32.0` to `0.33.0`
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.1` to `1.10.2`
Version 2.2.5 (January 20, 2025)
--------------------------------
* test(js): improve `.jshintrc` (strict comparison, etc...)
* test(sanitizer): add a fuzzer
* refactor(rewriter): use custom title case converter implementation instead of `golang.org/x/text/cases.Title()`
* refactor(readingtime): replace `whatlanggo` package with an ad-hoc implementation
* refactor(oauth2): no need to use `io.WriteString` when sha256 provides a way to obtain a sum in a single call
* refactor(js): simplify a bit `keyboard_handler.js`
* refactor(js): remove an outdated check for `{passive: true}`
* refactor(js): minor refactoring of `touch_handler.js`
* refactor(js): minor improvements in `app.js`
* refactor(database): add special handling for PostgreSQL-specific migrations
* fix(ui): reading preferences are reset if the form values are incorrect
* fix(sanitizer): allow `<hr>` tags
* fix(finder): do not add redirections to the list of subscriptions to avoid confusion
* fix: update Wallabag URL label to avoid confusion
* fix: improve pagination when having identical publication date
* fix: do not strip tags in Atom entry title
* feat(ntfy): Add option to use internal links
* feat(locale): update Polish translation
* feat(locale): update German translation
* feat(integration): add Discord integration
* feat(database): add optional build support for SQLite
* feat: validate usernames upon creation
* feat: replace `%{?systemd_requires}` with `%{?systemd_ordering}`
* feat: bump linter and minifier from ECMAScript 2017 to 2020 (ES11)
* feat: add `fix_ghost_cards` rewrite rule
* ci: tighten the CodeQL rules
* ci: run Docker tests only when the Dockerfiles are modified
* ci: run `-race -cover` only on Ubuntu jobs
* ci: don't specify languages for CodeQL
* ci: don't run `go vet ./...` as it's run as part of `golangci-lint`
* ci: checkout before installing Go to improve cache efficiency
* ci: avoid building Linux packages for each pull-request
* build(deps): bump `golang.org/x/oauth2` from `0.24.0` to `0.25.0`
* build(deps): bump `golang.org/x/net` from `0.33.0` to `0.34.0`
* build(deps): bump `golang.org/x/crypto` from `0.31.0` to `0.32.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.2` to `2.21.3`
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.0` to `1.10.1`
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.11.0` to `3.12.0`
Version 2.2.4 (December 20, 2024)
---------------------------------
* test(rewrite): add unit test for referer rewrite function
* refactor(subscription): use `strings.HasSuffix` instead of a regex in `FindSubscriptionsFromYouTubePlaylistPage`
* refactor(sanitizer): use `token.String()` instead of `html.EscapeString(token.Data)`
* refactor(sanitizer): simplify `isValidTag`
* refactor(sanitizer): simplify `hasRequiredAttributes`
* refactor(sanitizer): remove condition because `config.Opts` is guaranteed to never be nil
* refactor(sanitizer): remove a now-useless function after refactoring
* refactor(sanitizer): refactor conditions to highlight their similitude, enabling further refactoring
* refactor(sanitizer): optimize `strip_tags.go`
* refactor(sanitizer): micro-optimizations of `srcset.go`
* refactor(sanitizer): merge two conditions
* refactor(sanitizer): inline a function in `sanitizeAttributes` and fix a bug in it
* refactor(sanitizer): inline a condition in `sanitizeSrcsetAttr`
* refactor(sanitizer): improve `rewriteIframeURL()`
* refactor(sanitizer): Google+ isn't a thing anymore
* refactor(sanitizer): change the scope of a variable
* refactor(rewriter): replace regex with URL parsing for referrer override
* refactor(rewriter): avoid the use of regex in `addDynamicImage`
* refactor(rewrite): remove unused function arguments
* refactor(readability): various improvements and optimizations
* refactor(readability): simplify the regexes in `readability.go`
* refactor(processor): use URL parsing instead of a regex
* refactor(processor): improve the `rewrite` URL rule regex
* refactor(locale): delay parsing of translations until they're used
* refactor(js): factorise a line in `app.js`
* refactor(handler): delay `store.UserByID()` as much as possible
* refactor(css): replace `-ms-text-size-adjust` with `text-size-adjust`
* refactor(css): remove `-webkit-clip-path`
* refactor(css): factorise `.pagination-next` and `.pagination-last` together
* refactor: use a better construct than `doc.Find(…).First()`
* refactor: use `min/max` instead of `math.Min/math.Max`
* refactor: refactor `internal/reader/readability/testdata`
* refactor: optimize `sanitizeAttributes`
* refactor: get rid of `numberOfPluralFormsPerLanguage` test-only variable
* fix(storage): replace timezone function call with view
* fix(consistency): align feed modification behavior between API and UI
* fix(ci): fix grammar in pull-request template
* fix: load icon from site URL instead of feed URL
* fix: feed icon from xml ignored during force refresh
* feat(rewrite)!: remove `parse_markdown` rewrite rule
* feat(mediaproxy): update predefined referer spoofing rules for restricted media resources
* feat(locale): update translations to clarify readeck URL instead of readeck API endpoint
* feat(locale): update German translations
* feat(locale): update Chinese translations
* feat(apprise): update `SendNotification` to handle multiple entries and add logging
* feat(apprise): add title in notification request body
* feat: resize favicons before storing them in the database
* feat: optionally fetch watch time from YouTube API instead of website
* feat: only show the commit URL if it's not empty on `/about`
* feat: add predefined scraper rules for `arstechnica.com`
* feat: add date-based entry filtering rules
* chore: remove `blog.laravel.com` rewrite rule
* build(deps): bump `library/alpine` in `/packaging/docker/alpine` to `3.21`
* build(deps): bump `golang.org/x/term` from `0.26.0` to `0.27.0`
* build(deps): bump `golang.org/x/net` from `0.31.0` to `0.33.0`
* build(deps): bump `golang.org/x/crypto` from `0.30.0` to `0.31.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.1` to `2.21.2`
Version 2.2.3 (November 10, 2024)
---------------------------------
* fix: unable to change password due to a typo in SQL parameter
* fix: show only one player when there are several audio/video enclosures
* feat(mediaproxy): pass original filename in `Content-Disposition` header
* feat(mediaproxy): implement referer spoofing for restricted media resources
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
Version 2.2.2 (October 29, 2024)
--------------------------------
+1 -1
View File
@@ -145,7 +145,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
func (r *request) buildClient() http.Client {
return http.Client{
Timeout: time.Duration(defaultTimeout * time.Second),
Timeout: defaultTimeout * time.Second,
}
}
+13 -13
View File
@@ -3,21 +3,20 @@ module miniflux.app/v2
// +heroku goVersion go1.23
require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/abadojack/whatlanggo v1.0.1
github.com/PuerkitoBio/goquery v1.10.2
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/coreos/go-oidc/v3 v3.12.0
github.com/go-webauthn/webauthn v0.11.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.20.5
github.com/tdewolff/minify/v2 v2.21.1
github.com/yuin/goldmark v1.7.8
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/oauth2 v0.23.0
golang.org/x/term v0.25.0
golang.org/x/text v0.19.0
github.com/tdewolff/minify/v2 v2.21.3
golang.org/x/crypto v0.33.0
golang.org/x/image v0.24.0
golang.org/x/net v0.35.0
golang.org/x/oauth2 v0.26.0
golang.org/x/term v0.29.0
)
require (
@@ -27,7 +26,7 @@ require (
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
@@ -39,9 +38,10 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.18 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+59 -29
View File
@@ -1,17 +1,15 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
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.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -38,6 +36,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -54,10 +54,10 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8=
github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw=
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
github.com/tdewolff/parse/v2 v2.7.19/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=
@@ -66,52 +66,82 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.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=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+1
View File
@@ -121,6 +121,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
}
feedModificationRequest.Patch(originalFeed)
originalFeed.ResetErrorCounter()
if err := h.store.UpdateFeed(originalFeed); err != nil {
json.ServerError(w, r, err)
return
-5
View File
@@ -13,7 +13,6 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
@@ -153,10 +152,6 @@ func Parse() {
slog.Info("The default value for DATABASE_URL is used")
}
if err := locale.LoadCatalogMessages(); err != nil {
printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
}
if err := static.CalculateBinaryFileChecksums(); err != nil {
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
}
+18
View File
@@ -2116,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
}
}
func TestYouTubeApiKey(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
result := opts.YouTubeApiKey()
if result != expected {
t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
}
}
func TestYouTubeEmbedUrlOverride(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
+9
View File
@@ -60,6 +60,7 @@ const (
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false
defaultYouTubeApiKey = ""
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
defaultCreateAdmin = false
defaultAdminUsername = ""
@@ -149,6 +150,7 @@ type Options struct {
fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int
youTubeApiKey string
youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool
oauth2ClientID string
@@ -228,6 +230,7 @@ func NewOptions() *Options {
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeApiKey: defaultYouTubeApiKey,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
oauth2ClientID: defaultOAuth2ClientID,
@@ -503,6 +506,11 @@ func (o *Options) FetchYouTubeWatchTime() bool {
return o.fetchYouTubeWatchTime
}
// YouTubeApiKey returns the YouTube API key if defined.
func (o *Options) YouTubeApiKey() string {
return o.youTubeApiKey
}
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
@@ -733,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"SERVER_TIMING_HEADER": o.serverTimingHeader,
"WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize,
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
"WEBAUTHN": o.webAuthn,
}
+2
View File
@@ -271,6 +271,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
case "YOUTUBE_API_KEY":
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
case "YOUTUBE_EMBED_URL_OVERRIDE":
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
case "WATCHDOG":
+3 -19
View File
@@ -7,34 +7,18 @@ import (
"database/sql"
"fmt"
"log/slog"
"time"
// Postgresql driver import
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
driver := getDriverStr()
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
slog.String("driver", driver),
)
for version := currentVersion; version < schemaVersion; version++ {
@@ -45,7 +29,7 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx); err != nil {
if err := migrations[version](tx, driver); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
+194 -146
View File
@@ -10,8 +10,8 @@ import (
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = []func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
var migrations = []func(tx *sql.Tx, driver string) error{
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE schema_version (
version text not null
@@ -120,16 +120,19 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
sql := `
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN extra hstore;
CREATE INDEX users_extra_idx ON users using gin(extra);
`
_, err = tx.Exec(sql)
return err
`
_, err = tx.Exec(sql)
return err
}
return nil
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE tokens (
id text not null,
@@ -141,7 +144,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
@@ -149,7 +152,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE integrations (
user_id int not null,
@@ -170,27 +173,27 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE sessions rename to user_sessions`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
DROP TABLE tokens;
@@ -204,7 +207,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN wallabag_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN wallabag_url text default '';
@@ -216,12 +219,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
@@ -229,7 +232,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN nunux_keeper_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN nunux_keeper_url text default '';
@@ -238,17 +241,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN pocket_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN pocket_access_token text default '';
@@ -257,14 +260,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN username text default '';
ALTER TABLE feeds ADD COLUMN password text default '';
@@ -272,7 +275,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
@@ -281,12 +284,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
UPDATE
entries
@@ -296,17 +299,17 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
@@ -316,7 +319,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
UPDATE entries SET changed_at = published_at;
@@ -325,7 +328,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TABLE api_keys (
id serial not null,
@@ -341,7 +344,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN share_code text not null default '';
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
@@ -349,12 +352,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
@@ -362,52 +365,52 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN blocklist_rules text not null default '',
@@ -416,12 +419,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
UPDATE entries SET created_at = published_at;
@@ -429,7 +432,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, driver string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
@@ -440,63 +443,69 @@ var migrations = []func(tx *sql.Tx) error{
return err
}
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if driver == "postgresql" {
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
}
}
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
return err
}
}
_, err = tx.Exec(`
ALTER TABLE users DROP COLUMN extra;
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url);
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
@@ -504,7 +513,7 @@ var migrations = []func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE TABLE acme_cache (
key varchar(400) not null primary key,
@@ -514,13 +523,13 @@ var migrations = []func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
@@ -528,24 +537,24 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN telegram_bot_token text default '';
@@ -554,7 +563,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
@@ -562,7 +571,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN googlereader_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
@@ -571,7 +580,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN espial_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN espial_url text default '';
@@ -581,7 +590,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkding_url text default '';
@@ -590,38 +599,38 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_reading_speed int default 265;
ALTER TABLE users ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN matrix_bot_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN matrix_bot_user text default '';
@@ -632,18 +641,18 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users RENAME double_tap TO gesture_nav;
ALTER TABLE users ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end;
@@ -652,14 +661,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
@@ -667,14 +676,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
// Delete duplicated rows
sql := `
DELETE FROM enclosures a USING enclosures b
@@ -702,12 +711,12 @@ var migrations = []func(tx *sql.Tx) error{
return nil
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN notion_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN notion_token text default '';
@@ -716,7 +725,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readwise_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readwise_api_key text default '';
@@ -724,7 +733,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN apprise_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN apprise_url text default '';
@@ -733,7 +742,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN shiori_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN shiori_url text default '';
@@ -743,7 +752,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN shaarli_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN shaarli_url text default '';
@@ -752,13 +761,13 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
`)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN webhook_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN webhook_url text default '';
@@ -767,7 +776,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_topic_id int;
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f';
@@ -776,14 +785,14 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
-- Speed up has_enclosure
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
@@ -799,7 +808,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN rssbridge_url text default '';
@@ -807,7 +816,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
CREATE TABLE webauthn_credentials (
handle bytea primary key,
@@ -825,7 +834,7 @@ var migrations = []func(tx *sql.Tx) error{
`)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN omnivore_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN omnivore_api_key text default '';
@@ -834,7 +843,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkace_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkace_url text default '';
@@ -846,7 +855,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkwarden_url text default '';
@@ -855,7 +864,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
@@ -866,29 +875,29 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (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) {
func(tx *sql.Tx, _ string) (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) {
func(tx *sql.Tx, _ string) (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) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
@@ -898,12 +907,12 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE users
ADD COLUMN block_filter_entry_rules text not null default '',
@@ -912,7 +921,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN betula_url text default '';
ALTER TABLE integrations ADD COLUMN betula_token text default '';
@@ -921,7 +930,7 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN ntfy_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN ntfy_url text default '';
@@ -937,22 +946,22 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
@@ -960,4 +969,43 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN discord_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN discord_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN slack_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN slack_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN pushover_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN pushover_user text default '';
ALTER TABLE integrations ADD COLUMN pushover_token text default '';
ALTER TABLE integrations ADD COLUMN pushover_device text default '';
ALTER TABLE integrations ADD COLUMN pushover_prefix text default '';
ALTER TABLE feeds ADD COLUMN pushover_enabled bool default 'f';
ALTER TABLE feeds ADD COLUMN pushover_priority int default '0';
`
_, err = tx.Exec(sql)
return err
},
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
func getDriverStr() string {
return "postgresql"
}
+26
View File
@@ -0,0 +1,26 @@
//go:build sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}
func getDriverStr() string {
return "sqlite3"
}
+1 -1
View File
@@ -8,7 +8,7 @@ import "net/http"
// CookieValue returns the cookie value.
func CookieValue(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err == http.ErrNoCookie {
if err != nil {
return ""
}
+2 -2
View File
@@ -25,7 +25,7 @@ type Builder struct {
statusCode int
headers map[string]string
enableCompression bool
body interface{}
body any
}
// WithStatus uses the given status code to build the response.
@@ -41,7 +41,7 @@ func (b *Builder) WithHeader(key, value string) *Builder {
}
// WithBody uses the given body to build the response.
func (b *Builder) WithBody(body interface{}) *Builder {
func (b *Builder) WithBody(body any) *Builder {
b.body = body
return b
}
+60 -18
View File
@@ -16,19 +16,31 @@ import (
const contentTypeHeader = `application/json`
// OK creates a new JSON response with a 200 status code.
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
func OK(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSON(body))
builder.WithBody(responseBody)
builder.Write()
}
// Created sends a created response to the client.
func Created(w http.ResponseWriter, r *http.Request, body interface{}) {
func Created(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSON(body))
builder.WithBody(responseBody)
builder.Write()
}
@@ -62,10 +74,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(err))
builder.WithBody(responseBody)
builder.Write()
}
@@ -84,10 +103,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(err))
builder.WithBody(responseBody)
builder.Write()
}
@@ -105,10 +131,17 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
),
)
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("access unauthorized")))
builder.WithBody(responseBody)
builder.Write()
}
@@ -126,10 +159,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
),
)
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("access forbidden")))
builder.WithBody(responseBody)
builder.Write()
}
@@ -147,27 +187,29 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
),
)
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(toJSONError(errors.New("resource not found")))
builder.WithBody(responseBody)
builder.Write()
}
func toJSONError(err error) []byte {
func generateJSONError(err error) ([]byte, error) {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
return toJSON(errorMsg{ErrorMessage: err.Error()})
}
func toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
if err != nil {
slog.Error("Unable to marshal JSON response", slog.Any("error", err))
return []byte("")
return nil, err
}
return b
return encodedBody, nil
}
+2 -2
View File
@@ -293,12 +293,12 @@ func TestBuildInvalidJSONResponse(t *testing.T) {
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := ``
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
+39 -27
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
@@ -26,42 +27,53 @@ func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
}
func (c *Client) SendNotification(entry *model.Entry) error {
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
if c.baseURL == "" || c.servicesURL == "" {
return fmt.Errorf("apprise: missing base URL or services URL")
}
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
for _, entry := range entries {
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(map[string]any{
"urls": c.servicesURL,
"body": message,
})
if err != nil {
return fmt.Errorf("apprise: unable to encode request body: %v", err)
}
requestBody, err := json.Marshal(map[string]any{
"urls": c.servicesURL,
"body": message,
"title": feed.Title,
})
if err != nil {
return fmt.Errorf("apprise: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("apprise: unable to create request: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("apprise: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
slog.Debug("Sending Apprise notification",
slog.String("apprise_url", c.baseURL),
slog.String("services_url", c.servicesURL),
slog.String("title", feed.Title),
slog.String("body", message),
slog.String("entry_url", entry.URL),
)
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
}
}
return nil
+109
View File
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Discord Webhooks documentation: https://discord.com/developers/docs/resources/webhook
package discord // import "miniflux.app/v2/internal/integration/discord"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const discordMsgColor = 5793266
type Client struct {
webhookURL string
}
func NewClient(webhookURL string) *Client {
return &Client{webhookURL: webhookURL}
}
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("discord: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("discord: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Discord notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
}
}
return nil
}
type discordFields struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
type discordEmbed struct {
Title string `json:"title"`
Color int `json:"color"`
Fields []discordFields `json:"fields"`
}
type discordMessage struct {
Embeds []discordEmbed `json:"embeds"`
}
+89 -33
View File
@@ -10,6 +10,7 @@ import (
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace"
@@ -22,11 +23,13 @@ import (
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/pushover"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
"miniflux.app/v2/internal/integration/shiori"
"miniflux.app/v2/internal/integration/slack"
"miniflux.app/v2/internal/integration/telegrambot"
"miniflux.app/v2/internal/integration/wallabag"
"miniflux.app/v2/internal/integration/webhook"
@@ -472,20 +475,27 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if feed.WebhookURL != "" {
webhookURL = feed.WebhookURL
} else {
webhookURL = userIntegrations.WebhookURL
}
slog.Debug("Sending new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
slog.Any("error", err),
)
}
@@ -505,6 +515,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
userIntegrations.NtfyUsername,
userIntegrations.NtfyPassword,
userIntegrations.NtfyIconURL,
userIntegrations.NtfyInternalLinks,
feed.NtfyPriority,
)
@@ -513,8 +524,82 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending new entries to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
}
}
if userIntegrations.DiscordEnabled {
slog.Debug("Sending new entries to Discord",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := discord.NewClient(
userIntegrations.DiscordWebhookLink,
)
if err := client.SendDiscordMsg(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Discord", slog.Any("error", err))
}
}
if userIntegrations.SlackEnabled {
slog.Debug("Sending new entries to Slack",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := slack.NewClient(
userIntegrations.SlackWebhookLink,
)
if err := client.SendSlackMsg(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Slack", slog.Any("error", err))
}
}
if userIntegrations.PushoverEnabled && feed.PushoverEnabled {
slog.Debug("Sending new entries to Pushover",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
userIntegrations.PushoverDevice,
userIntegrations.PushoverPrefix,
)
if err := client.SendMessages(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Pushover", slog.Any("error", err))
}
}
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
@@ -541,35 +626,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
)
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending a new entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(entry); err != nil {
slog.Error("Unable to send entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
slog.Any("error", err),
)
}
}
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
return c == ',' || c == ' '
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v2/links")
if err != nil {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
+15 -3
View File
@@ -9,8 +9,10 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -22,14 +24,15 @@ const (
type Client struct {
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
ntfyInternalLinks bool
ntfyPriority int
}
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyPriority int) *Client {
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyInternalLinks bool, ntfyPriority int) *Client {
if ntfyURL == "" {
ntfyURL = defaultNtfyURL
}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyPriority}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
@@ -46,12 +49,21 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
ntfyMessage.Icon = c.ntfyIconURL
}
if c.ntfyInternalLinks {
url, err := url.Parse(config.Opts.BaseURL())
if err != nil {
slog.Error("Unable to parse base URL", slog.Any("error", err))
} else {
ntfyMessage.Click = fmt.Sprintf("%s%s%d", url, "/unread/entry/", entry.ID)
}
}
slog.Debug("Sending Ntfy message",
slog.String("url", c.ntfyURL),
slog.String("topic", c.ntfyTopic),
slog.Int("priority", ntfyMessage.Priority),
slog.String("message", ntfyMessage.Message),
slog.String("entry_url", entry.URL),
slog.String("entry_url", ntfyMessage.Click),
)
if err := c.makeRequest(ntfyMessage); err != nil {
+138
View File
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pushover // import "miniflux.app/v2/internal/integration/pushover"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultPushoverURL = "https://api.pushover.net"
)
type Client struct {
prefix string
token string
user string
device string
priority int
}
type Message struct {
Token string `json:"token"`
User string `json:"user"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
URL string `json:"url"`
URLTitle string `json:"url_title"`
Device string `json:"device,omitempty"`
}
type ErrorResponse struct {
User string `json:"user"`
Errors []string `json:"errors"`
Status int `json:"status"`
Request string `json:"request"`
}
func New(user, token string, priority int, device, urlPrefix string) *Client {
if urlPrefix == "" {
urlPrefix = defaultPushoverURL
}
if priority < -2 {
priority = -2
}
if priority > 2 {
priority = 2
}
return &Client{
user: user,
token: token,
device: device,
prefix: urlPrefix,
priority: priority,
}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
if c.token == "" || c.user == "" {
return fmt.Errorf("pushover token and user are required")
}
for _, entry := range entries {
msg := &Message{
User: c.user,
Token: c.token,
Device: c.device,
Message: entry.Title,
Title: feed.Title,
Priority: c.priority,
URL: entry.URL,
}
slog.Debug("Sending Pushover message",
slog.Int("priority", msg.Priority),
slog.String("message", msg.Message),
slog.String("entry_url", msg.URL),
)
if err := c.makeRequest(msg); err != nil {
return fmt.Errorf("c.makeRequest: %w", err)
}
}
return nil
}
func (c *Client) makeRequest(payload *Message) error {
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
url := c.prefix + "/1/messages.json"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("httpClient.Do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
errorMessage := resp.Status
var errResp ErrorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
if len(errResp.Errors) > 0 {
errorMessage = strings.Join(errResp.Errors, ",")
}
}
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
}
return nil
}
+26 -12
View File
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: missing base URL, username or password")
}
sessionID, err := c.authenticate()
token, err := c.authenticate()
if err != nil {
return fmt.Errorf("shiori: unable to authenticate: %v", err)
}
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
if err != nil {
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Session-Id", sessionID)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return nil
}
func (c *Client) authenticate() (sessionID string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
func (c *Client) authenticate() (token string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
}
return authResponse.SessionID, nil
return authResponse.Message.Token, nil
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
type authResponse struct {
OK bool `json:"ok"`
Message authResponseMessage `json:"message"`
}
type authResponseMessage struct {
SessionID string `json:"session"`
Token string `json:"token"`
}
type addBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"createArchive"`
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"create_archive"`
CreateEbook bool `json:"create_ebook"`
Public int `json:"public"`
Excerpt string `json:"excerpt"`
Tags []string `json:"tags"`
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks
package slack // import "miniflux.app/v2/internal/integration/slack"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const slackMsgColor = "#5865F2"
type Client struct {
webhookURL string
}
func NewClient(webhookURL string) *Client {
return &Client{webhookURL: webhookURL}
}
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("slack: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("slack: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Slack notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
}
}
return nil
}
type slackFields struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short,omitempty"`
}
type slackAttachments struct {
Title string `json:"title"`
Color string `json:"color"`
Fields []slackFields `json:"fields"`
}
type slackMessage struct {
Attachments []slackAttachments `json:"attachments"`
}
+12 -3
View File
@@ -12,17 +12,26 @@ import (
type translationDict map[string]interface{}
type catalog map[string]translationDict
var defaultCatalog catalog
var defaultCatalog = make(catalog, len(AvailableLanguages))
//go:embed translations/*.json
var translationFiles embed.FS
func GetTranslationDict(language string) (translationDict, error) {
if _, ok := defaultCatalog[language]; !ok {
var err error
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
return nil, err
}
}
return defaultCatalog[language], nil
}
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
defaultCatalog = make(catalog, len(AvailableLanguages()))
for language := range AvailableLanguages() {
for language := range AvailableLanguages {
defaultCatalog[language], err = loadTranslationFile(language)
if err != nil {
return err
+24 -3
View File
@@ -39,7 +39,7 @@ func TestLoadCatalog(t *testing.T) {
}
func TestAllKeysHaveValue(t *testing.T) {
for language := range AvailableLanguages() {
for language := range AvailableLanguages {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
@@ -71,7 +71,7 @@ func TestMissingTranslations(t *testing.T) {
t.Fatal(`Unable to parse reference language`)
}
for language := range AvailableLanguages() {
for language := range AvailableLanguages {
if language == refLang {
continue
}
@@ -90,7 +90,28 @@ func TestMissingTranslations(t *testing.T) {
}
func TestTranslationFilePluralForms(t *testing.T) {
for language := range AvailableLanguages() {
var numberOfPluralFormsPerLanguage = map[string]int{
"en_US": 2,
"es_ES": 2,
"fr_FR": 2,
"de_DE": 2,
"pl_PL": 3,
"pt_BR": 2,
"zh_CN": 1,
"zh_TW": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"ru_RU": 3,
"it_IT": 2,
"ja_JP": 1,
"tr_TR": 2,
"el_EL": 2,
"fi_FI": 2,
"hi_IN": 2,
"uk_UA": 3,
"id_ID": 1,
}
for language := range AvailableLanguages {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
+21 -43
View File
@@ -3,47 +3,25 @@
package locale // import "miniflux.app/v2/internal/locale"
var numberOfPluralFormsPerLanguage = map[string]int{
"en_US": 2,
"es_ES": 2,
"fr_FR": 2,
"de_DE": 2,
"pl_PL": 3,
"pt_BR": 2,
"zh_CN": 1,
"zh_TW": 1,
"nl_NL": 2,
"ru_RU": 3,
"it_IT": 2,
"ja_JP": 1,
"tr_TR": 2,
"el_EL": 2,
"fi_FI": 2,
"hi_IN": 2,
"uk_UA": 3,
"id_ID": 1,
}
// AvailableLanguages returns the list of available languages.
func AvailableLanguages() map[string]string {
return map[string]string{
"en_US": "English",
"es_ES": "Español",
"fr_FR": "Français",
"de_DE": "Deutsch",
"pl_PL": "Polski",
"pt_BR": "Português Brasileiro",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
"nl_NL": "Nederlands",
"ru_RU": "Русский",
"it_IT": "Italiano",
"ja_JP": "日本語",
"tr_TR": "Türkçe",
"el_EL": "Ελληνικά",
"fi_FI": "Suomi",
"hi_IN": "हिन्दी",
"uk_UA": "Українська",
"id_ID": "Bahasa Indonesia",
}
// AvailableLanguages is the list of available languages.
var AvailableLanguages = map[string]string{
"en_US": "English",
"es_ES": "Español",
"fr_FR": "Français",
"de_DE": "Deutsch",
"pl_PL": "Polski",
"pt_BR": "Português Brasileiro",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"ru_RU": "Русский",
"it_IT": "Italiano",
"ja_JP": "日本語",
"tr_TR": "Türkçe",
"el_EL": "Ελληνικά",
"fi_FI": "Suomi",
"hi_IN": "हिन्दी",
"uk_UA": "Українська",
"id_ID": "Bahasa Indonesia",
}
+1 -1
View File
@@ -6,7 +6,7 @@ package locale // import "miniflux.app/v2/internal/locale"
import "testing"
func TestAvailableLanguages(t *testing.T) {
results := AvailableLanguages()
results := AvailableLanguages
for k, v := range results {
if k == "" {
t.Errorf(`Empty language key detected`)
+4 -1
View File
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
var pluralForms = map[string](func(n int) int){
var pluralForms = map[string]func(n int) int{
// nplurals=2; plural=(n != 1);
"default": func(n int) int {
if n != 1 {
@@ -88,6 +88,9 @@ var pluralForms = map[string](func(n int) int){
"zh_TW": func(n int) int {
return 0
},
"nan_Latn_pehoeji": func(n int) int {
return 0
},
}
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+4
View File
@@ -77,6 +77,10 @@ func TestPluralRules(t *testing.T) {
1: 0,
5: 0,
},
"nan_Latn_pehoeji": {
1: 0,
5: 0,
},
}
for rule, values := range scenarios {
+19 -14
View File
@@ -11,9 +11,11 @@ type Printer struct {
}
func (p *Printer) Print(key string) string {
if str, ok := defaultCatalog[p.language][key]; ok {
if translation, ok := str.(string); ok {
return translation
if dict, err := GetTranslationDict(p.language); err == nil {
if str, ok := dict[key]; ok {
if translation, ok := str.(string); ok {
return translation
}
}
}
return key
@@ -21,16 +23,16 @@ func (p *Printer) Print(key string) string {
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) string {
var translation string
translation := key
str, found := defaultCatalog[p.language][key]
if !found {
translation = key
} else {
var valid bool
translation, valid = str.(string)
if !valid {
translation = key
if dict, err := GetTranslationDict(p.language); err == nil {
str, found := dict[key]
if found {
var valid bool
translation, valid = str.(string)
if !valid {
translation = key
}
}
}
@@ -39,9 +41,12 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
// Plural returns the translation of the given key by using the language plural form.
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
choices, found := defaultCatalog[p.language][key]
dict, err := GetTranslationDict(p.language)
if err != nil {
return key
}
if found {
if choices, found := dict[key]; found {
var plurals []string
switch v := choices.(type) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,587 @@
{
"action.cancel": "Chhú-siau",
"action.download": "Lia̍h----loh-lâi",
"action.edit": "Pian-chi̍p",
"action.home_screen": "Chng tī chú ōe-bīn",
"action.import": "Hōe--li̍p",
"action.login": "Teng-lo̍k",
"action.or": "ah-sī",
"action.remove": "Thâi tiāu",
"action.remove_feed": "Thâi tiāu chit ê siau-sit lâi-goân",
"action.save": "Pó-chûn",
"action.subscribe": "Tēng",
"action.update": "Ōaⁿ-sin",
"alert.account_linked": "Í-keng kah lí ê gōa-pō͘ kháu-chō kiat chòe-hé--ah!",
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
"alert.no_bookmark": "Chit-má ah bô siu-chông",
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
"alert.no_feed_entry": "Chit ê siau-sit lâi-goân lāi bô siau-sit",
"alert.no_feed_in_category": "Bô chit ê lūi-pia̍t ê siau-sit lâi-goân",
"alert.no_history": "Chit-má ah bô kì-lo̍k",
"alert.no_search_result": "Bô hû-ha̍p ê chhiau-chhē kiat-kó",
"alert.no_shared_entry": "Chit-má ah bô hun-hióng ê siau-sit",
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
"alert.pocket_linked": "Í-keng kā lí ê Pocket kháu-chō kiat chòe-hé--ah!",
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
"alert.too_many_feeds_refresh": [
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
],
"confirm.loading": "Tng leh chip-hêng…",
"confirm.no": "Hóⁿ",
"confirm.question": "Kám ū khak-tēng?",
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
"confirm.yes": "Sī",
"enclosure_media_controls.seek": "sóa-ūi:",
"enclosure_media_controls.seek.title": "sóa %s bió",
"enclosure_media_controls.speed": "Sok-tō͘",
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
"enclosure_media_controls.speed.reset": "Têng siat-tēng",
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
"enclosure_media_controls.speed.slower": "Pàng bān",
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
"entry.comments.label": "Hôe-èng",
"entry.comments.title": "Khòaⁿ hôe-èng",
"entry.estimated_reading_time": [
"Ài %d hun-cheng lâi tha̍k"
],
"entry.external_link.label": "Gōa-pō͘ liân-kiat",
"entry.save.completed": "Pó-chûn chò soah",
"entry.save.label": "Pó-chûn",
"entry.save.title": "Pó-chûn chit ê siau-sit",
"entry.save.toast.completed": "Pó-chûn chò soah",
"entry.scraper.completed": "Lia̍h soah--ah",
"entry.scraper.label": "Lia̍h--lo̍h-lâi",
"entry.scraper.title": "Lia̍h goân-tóe lōe-iông",
"entry.share.label": "Hun-hióng",
"entry.share.title": "Hun-hióng chit ê siau-sit",
"entry.shared_entry.label": "Hun-hióng",
"entry.shared_entry.title": "Phah khui kong-khai ê liân-kiat",
"entry.state.loading": "Tng leh chip-hêng…",
"entry.state.saving": "Tng leh pó-chûn…",
"entry.status.read": "Chù chòe tha̍k--kè",
"entry.status.title": "Kái chōng-thài",
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
"entry.status.unread": "Chù chòe ah-bōe tha̍k",
"entry.tags.label": "Khan-á:",
"entry.unshare.label": "Chhú-siau hun-hióng",
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
"error.category_already_exists": "Lūi-pia̍t í-keng chûn-chāi.",
"error.category_not_found": "Chit ê lūi-pia̍t bô chûn-chāi ah-sī bô sio̍k-tī lí.",
"error.database_error": "Chu-liāu khò͘ ū m̄-tiō: %v.",
"error.different_passwords": "Su-li̍p ê bi̍t-bé chit nn̄g pái bô kâng.",
"error.duplicate_fever_username": "Fever ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
"error.duplicate_googlereader_username": "Google Reader ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
"error.duplicate_linked_account": "Chit ê beh kiat chòe-hé--ê í-keng seng hō͘ lâng kiat khì--ah!",
"error.duplicated_feed": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
"error.empty_file": "Chit ê tóng-àn sī khang--ê.",
"error.entries_per_page_invalid": "Ta̍k ia̍h ê siau-sit sò͘ ū būn-tôe.",
"error.feed_already_exists": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
"error.feed_category_not_found": "Bô chit ê lūi-pia̍t ah-sī kóng bô sio̍k-tī chit ê sú-iōng-lâng.",
"error.feed_format_not_detected": "Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.",
"error.feed_invalid_blocklist_rule": "Hong-só kui-chek bô-hāu.",
"error.feed_invalid_keeplist_rule": "Pó-liû kui-chek bô-hāu.",
"error.feed_mandatory_fields": "Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.",
"error.feed_not_found": "Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí",
"error.feed_title_not_empty": "Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.",
"error.feed_url_not_empty": "Beh tēng ê siau-sit lâi-goân bāng-chí bōe-sái sī khang--ê.",
"error.fields_mandatory": "Tio̍h-ài kā chu-liāu lóng siá chê.",
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
"error.http_gateway_timeout": "Tán chit ê bāng-chām ê hôe-èng í-keng chhiau-kè sî-kan, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_internal_server_error": "Chit ê bāng-chām ê su-hāu-khì in ka-kī ū būn-tôe, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_not_authorized": "Bô khoân chûn-chhú chit ê bāng-chām, chhiáⁿ kiám-cha kháu-chō miâ kah bi̍t-bé。",
"error.http_resource_not_found": "Chhē bô chit ê liân-kiat, chhiáⁿ khak-līn bāng-chí kám ū chèng-khak.",
"error.http_response_too_large": "HTTP hôe-èng siuⁿ tōa. Lí ē-sái tī choân-he̍k siat-tēng lāi kā siōng koân hān-tō͘ kái khah koân (ài têng khui su-hāu-khì)。",
"error.http_service_unavailable": "Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôem̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_too_many_requests": "Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.",
"error.http_unexpected_status_code": "Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.invalid_default_home_page": "Ū-siat chú-ia̍h ū būn-tôe!",
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
"error.invalid_site_url": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí ū būn-tôe.",
"error.invalid_theme": "Ū būn-tôe ê chú-tôe.",
"error.invalid_timezone": "Ū būn-tôe ê sî-khu.",
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
"error.pocket_access_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h access token",
"error.pocket_request_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h request token",
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
"error.settings_block_rule_separator_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
"error.settings_invalid_domain_list": "Bāng-he̍k chheng-toaⁿ ū būn-tôe, chhiáⁿ iōng khang-keh keh khui bô kâng ê bāng-he̍k.",
"error.settings_keep_rule_fieldname_invalid": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
"error.settings_keep_rule_invalid_regex": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
"error.settings_keep_rule_regex_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
"error.settings_keep_rule_separator_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
"error.settings_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ, chú-tôe, gú-giân, sî-khu.",
"error.settings_media_playback_rate_range": "Pàng ê sok-tō͘ chhiau-kè hoān-ûi",
"error.settings_reading_speed_is_positive": "Tha̍k ê sok-tō͘ tio̍h-ài sī chiaⁿ chéng-sò͘",
"error.site_url_not_empty": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí bōe-sái sī khang--ê.",
"error.subscription_not_found": "Chhē bōe tio̍h līm-hô tēng ê siau-sit lâi-goân",
"error.title_required": "Tio̍h-ài su-li̍p piau-tôe.",
"error.tls_error": "TLS m̄-tio̍h: %q。Nā-sī beh pàng-ba̍k TSL chèng-bêng, ē-sái tī siau-sit lâi-goân siat-tēng lāi thêng-tiong.",
"error.unable_to_create_api_key": "Bô-hoat-tō͘ sin cheng-ka chit ê API só-sî.",
"error.unable_to_create_category": "Bô-hoat-tō͘ sin cheng-ka chit ê lūi-pia̍t",
"error.unable_to_create_user": "Bô-hoat-tō͘ sin cheng-ka chit ê sú-iōng-lâng",
"error.unable_to_detect_rssbridge": "Sú-iōng RSS-Bridge sî chhē bô līm-hô siau-sit lâi-goân: %v.",
"error.unable_to_parse_feed": "Bô-hoat-tō͘ kái-sek chit ê siau-sit lâi-goân: %v.",
"error.unable_to_update_category": "Bô-hoat-tō͘ ōaⁿ-sin chit ê lūi-pia̍t",
"error.unable_to_update_feed": "Bô-hoat-tō͘ ōaⁿ-sin chit ê siau-sit lâi-goân",
"error.unable_to_update_user": "Bô-hoat-tō͘ ōaⁿ-sin chit ê sú-iōng-lâng",
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
"form.api_key.label.description": "API só-sîkhan-á",
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
"form.category.label.title": "Piau-tôe",
"form.feed.fieldset.general": "Thong-iōng",
"form.feed.fieldset.integration": "Tē-saⁿ hong ho̍k-bū",
"form.feed.fieldset.network_settings": "Bāng-lō͘ siat-tēng",
"form.feed.fieldset.rules": "Kui-chek",
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.feed.label.blocklist_rules": "Hong-só kui-chek",
"form.feed.label.category": "lūi-pia̍t",
"form.feed.label.cookie": "Siat-tēng Cookies",
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
"form.feed.label.description": "Biâu-su̍t",
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
"form.feed.label.feed_password": "Siau-sit lâi-goân bi̍t-bé",
"form.feed.label.feed_url": "Siau-sit lâi-goân bāng-chí",
"form.feed.label.feed_username": "Siau-sit lâi-goân kháu-chō miâ",
"form.feed.label.fetch_via_proxy": "Thàu-kè tāi-lí lia̍h",
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
"form.feed.label.keeplist_rules": "Pó-liû kui-chek",
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
"form.feed.label.ntfy_high_priority": "Ntfy koân iu-sian sūn-sū",
"form.feed.label.ntfy_low_priority": "Ntfy kē iu-sian sūn-sū",
"form.feed.label.ntfy_max_priority": "Ntfy siōng koân iu-sian sūn-sū",
"form.feed.label.ntfy_min_priority": "Ntfy siōng kē iu-sian sūn-sū",
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
"form.feed.label.pushover_activate": "Push entries to Pushover",
"form.feed.label.pushover_default_priority": "Default priority",
"form.feed.label.pushover_high_priority": "High priority",
"form.feed.label.pushover_low_priority": "Low priority",
"form.feed.label.pushover_max_priority": "Max priority",
"form.feed.label.pushover_min_priority": "Minimal priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Têng siá kui-chek",
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
"form.feed.label.site_url": "Bāng-chām bāng-chí",
"form.feed.label.title": "Piau-tôe",
"form.feed.label.urlrewrite_rules": "Bāng-chí têng siá kui-chek",
"form.feed.label.user_agent": "Ngī kái sú-iōng-lâng tāi-lí",
"form.feed.label.webhook_url": "Ngī kái webhook bāng-chí",
"form.import.label.file": "OPML tóng-àn",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Thui sàng siau-sit khì Apprise",
"form.integration.apprise_services_url": "Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.integration.apprise_url": "Apprise API bāng-chí",
"form.integration.betula_activate": "Pó-chûn siau-sit kàu Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula su-hāu-khì bāng-chí",
"form.integration.cubox_activate": "Pó-chûn siau-sit khì Cubox",
"form.integration.cubox_api_link": "Cubox API liân-kiat",
"form.integration.discord_activate": "Thui-sàng siau-sit kàu Discord",
"form.integration.discord_webhook_link": "Discord Webhook liân-kiat",
"form.integration.espial_activate": "Pó-chûn siau-sit kàu Espial",
"form.integration.espial_api_key": "Espial API só-sî",
"form.integration.espial_endpoint": "Espial API thâu",
"form.integration.espial_tags": "Espial khan-á",
"form.integration.fever_activate": "Khai-sí iōng Fever API",
"form.integration.fever_endpoint": "Fever API thâu",
"form.integration.fever_password": "Fever bi̍t-bé",
"form.integration.fever_username": "Fever kháu-chō miâ",
"form.integration.googlereader_activate": "Khai-sí iōng Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API thâu",
"form.integration.googlereader_password": "Google Reader bi̍t-bé",
"form.integration.googlereader_username": "Google Reader Kháu-chō miâ",
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
"form.integration.linkace_api_key": "LinkAce API só-sî",
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
"form.integration.linkace_endpoint": "LinkAce API thâu",
"form.integration.linkace_is_private": "Chù chòe su-lîn ê liân-kiat",
"form.integration.linkace_tags": "LinkAce khan-á",
"form.integration.linkding_activate": "Pó-chûn siau-sit kàu Linkding",
"form.integration.linkding_api_key": "Linkding API só-sî",
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.linkding_endpoint": "Linkding API thâu",
"form.integration.linkding_tags": "Linkding khan-á",
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
"form.integration.linkwarden_endpoint": "Linkwarden API thâu",
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
"form.integration.matrix_bot_url": "Matrix su-hāu-khìbāng-chí",
"form.integration.matrix_bot_user": "Matrix kháu-chō miâ",
"form.integration.notion_activate": "Pó-chûn siau-sit kàu Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Thui-sàng siau-sit kàu Ntfy",
"form.integration.ntfy_api_token": "Ntfy API só-sî (soán thiⁿ)",
"form.integration.ntfy_icon_url": "Ntfy Icon bāng-chí (soán thiⁿ)",
"form.integration.ntfy_internal_links": "Tiám ê sî-chūn iōng lāi-pō͘ liân-kiat (soán thiⁿ)",
"form.integration.ntfy_password": "Ntfy bi̍t-bé (soán thiⁿ)",
"form.integration.ntfy_topic": "Ntfy topic",
"form.integration.ntfy_url": "Ntfy bāng-chí (soán thiⁿ, ū-siat sī ntfy.sh)",
"form.integration.ntfy_username": "Ntfy kháu-chō miâ (soán thiⁿ)",
"form.integration.nunux_keeper_activate": "Pó-chûn siau-sit kàu Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API só-sî",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API thâu",
"form.integration.omnivore_activate": "Pó-chûn siau-sit kàu Omnivore",
"form.integration.omnivore_api_key": "Omnivore API só-sî",
"form.integration.omnivore_url": "Omnivore API thâu",
"form.integration.pinboard_activate": "Pó-chûn siau-sit kàu Pinboard",
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.pinboard_tags": "Pinboard khan-á",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket token",
"form.integration.pocket_activate": "Pó-chûn siau-sit kàu Pocket",
"form.integration.pocket_connect_link": "Kah Pocket kháu-chō kiat chòe-hé",
"form.integration.pocket_consumer_key": "Pocket sú-iōng-lâng só-sî",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "khan-á (iōng tō͘-tiám keh khui)",
"form.integration.raindrop_token": "Raindrop Acess Token",
"form.integration.readeck_activate": "Pó-chûn siau-sit kàu Readeck",
"form.integration.readeck_api_key": "Readeck API só-sî",
"form.integration.readeck_endpoint": "Readeck API thâu",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
"form.integration.readwise_activate": "Pó-chûn siau-sit kàu Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Acess Token",
"form.integration.readwise_api_key_link": "Chhú-tek lí ê Readwise Acess Token",
"form.integration.rssbridge_activate": "Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge su-hāu-khì的bāng-chí",
"form.integration.shaarli_activate": "Pó-chûn siau-sit kàu Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API só-sî",
"form.integration.shaarli_endpoint": "Shaarli bāng-chí",
"form.integration.shiori_activate": "Pó-chûn siau-sit kàu Shiori",
"form.integration.shiori_endpoint": "Shiori API thâu",
"form.integration.shiori_password": "Shiori bi̍t-bé",
"form.integration.shiori_username": "Shiori kháu-chō miâ",
"form.integration.slack_activate": "Thui-sàng siau-sit kàu Slack",
"form.integration.slack_webhook_link": "Slack Webhook liân-kiat",
"form.integration.telegram_bot_activate": "Thui-sàng siau-sit kàu Telegram",
"form.integration.telegram_bot_disable_buttons": "Mài hián-sī khai-koan",
"form.integration.telegram_bot_disable_notification": "Têng iōng thong-ti",
"form.integration.telegram_bot_disable_web_page_preview": "Thêng iōng bāng-ia̍h ū-lám",
"form.integration.telegram_bot_token": "Bot Token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "Pó-chûn siau-sit kàu Wallabag",
"form.integration.wallabag_client_id": "Wallabag kheh-hō͘ thâu ID",
"form.integration.wallabag_client_secret": "Wallabag kheh-hō͘ thâu só-sî",
"form.integration.wallabag_endpoint": "Wallabag Base URL",
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook bāng-chí",
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
"form.prefs.label.custom_css": "Chū tēng ê CSS",
"form.prefs.label.custom_js": "Chū tēng ê JavaScript",
"form.prefs.label.default_home_page": "Ū-siat chú-ia̍h",
"form.prefs.label.default_reading_speed": "Kî-thaⁿ gú-giân tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī)",
"form.prefs.label.display_mode": "Chiām-chìn sek bāng-lō͘ èng-iōng theng-sek (PWA) ê hián-sī bô͘-sek",
"form.prefs.label.entries_per_page": "Ta̍k ia̍h siau-sit sò͘",
"form.prefs.label.entry_order": "Siau-sit hián-sī sūn-sū ê i-kù",
"form.prefs.label.entry_sorting": "Siau-sit sūn-sū",
"form.prefs.label.entry_swipe": "Ē-sái tī chhiok-khòng sek êng-bō͘ ùi siau-sit iōng thoa tāng chhau-chok",
"form.prefs.label.external_font_hosts": "Gōa-pō͘ lī-hêng lâi-goân",
"form.prefs.label.gesture_nav": "Tī siau-sit kan sóa-ūi ê chhiú-sè",
"form.prefs.label.keyboard_shortcuts": "Ē-sái iōng khí-pôaⁿ khoài-sok khí",
"form.prefs.label.language": "Gú-giân",
"form.prefs.label.mark_read_manually": "Ka-kī chhau-chok kám beh chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_media_completion": "Kan-na tī im-sìn, sī-sìn hòng-sàng kàu 90%% ê si-chun chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
"form.prefs.label.theme": "Chú-tôe",
"form.prefs.label.timezone": "Sî-khu",
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
"form.prefs.select.browser": "Tī iû-lám khì phah khui",
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
"form.prefs.select.fullscreen": "Choân êng-bō͘",
"form.prefs.select.minimal_ui": "Siōng iông-chhun--ê",
"form.prefs.select.none": "Bô",
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
"form.prefs.select.recent_first": "Ùi sin--ê khai-sí pâi",
"form.prefs.select.standalone": "To̍k-li̍p--ê",
"form.prefs.select.swipe": "Iōng thoa--ê",
"form.prefs.select.tap": "Tiám nn̄g pái",
"form.prefs.select.unread_count": "Ah-bōe tha̍k ê sò͘-liōng",
"form.submit.loading": "Tng leh chip-hêng…",
"form.submit.saving": "Tng leh pó-chûn…",
"form.user.label.admin": "Koán-lí-lâng",
"form.user.label.confirmation": "Koh su-li̍p chi̍t pái bi̍t-bé",
"form.user.label.password": "Bi̍t-bé",
"form.user.label.username": "Kháu-chō miâ",
"menu.about": "Iú-koan",
"menu.add_feed": "Sin cheng-ka siau-sit lâi-goân",
"menu.add_user": "Sin cheng-ka sú-iōng-lâng",
"menu.api_keys": "API só-sî",
"menu.categories": "Lūi-pia̍t",
"menu.create_api_key": "Sin cheng-ka chi̍t ê API só-sî",
"menu.create_category": "Sin cheng-ka lūi-pia̍t",
"menu.edit_category": "Pian-chi̍p",
"menu.edit_feed": "Pian-chi̍p",
"menu.export": "Hōe--chhut",
"menu.feed_entries": "Bûn-chiong",
"menu.feeds": "Siau-sit lâi-goân",
"menu.flush_history": "Hìⁿ-sak kì-lo̍k",
"menu.history": "Kì-lo̍k",
"menu.home_page": "Siú ia̍h",
"menu.import": "Hōe--li̍p",
"menu.integrations": "Chéng-ha̍p",
"menu.logout": "Teng-chhut",
"menu.mark_all_as_read": "Choân-pō͘ chù chòe tha̍k kè",
"menu.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k kè",
"menu.preferences": "Siat-tēng",
"menu.refresh_all_feeds": "Tī pōe-āu têng lia̍h só͘-ū ê siau-sit lâi-goân",
"menu.refresh_feed": "Têng lia̍h",
"menu.search": "Chhiau-chhē",
"menu.sessions": "Ū teng-lo̍k--ê",
"menu.settings": "Siat-tēng",
"menu.shared_entries": "Hun-hióng kè ê siau-sit",
"menu.show_all_entries": "Hián-sī só͘-ū ê siau-sit",
"menu.show_only_starred_entries": "Kan-na hián-sī siu-chông ê siau-sit",
"menu.show_only_unread_entries": "Kan-na hián-sī ah-bōe tha̍k kè ê siau-sit",
"menu.starred": "Siu-chông",
"menu.title": "Tō-lám",
"menu.unread": "Ah-bōe tha̍k",
"menu.users": "Sú-iōng-lâng",
"page.about.author": "Chok-chiá: ",
"page.about.build_date": "Kiàn-tì li̍t-kî:",
"page.about.credits": "Pán-koân",
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
"page.about.go_version": "Go pán-pún:",
"page.about.license": "Pàng-koân:",
"page.about.postgres_version": "Postgres pán-pún:",
"page.about.title": "Iú-koan",
"page.about.version": "Pán-pún:",
"page.add_feed.choose_feed": "Soán-te̍k chi̍t ê Siau-sit lâi-goân",
"page.add_feed.label.url": "Bāng-chí",
"page.add_feed.legend.advanced_options": "Chìn-kai soán-hāng",
"page.add_feed.no_category": "Ah bô lūi-pia̍t, chì-chió ài ū chi̍t ê",
"page.add_feed.submit": "Chhē Siau-sit lâi-goân",
"page.add_feed.title": "Sin cheng-ka Siau-sit lâi-goân",
"page.api_keys.never_used": "Bô iōng kè",
"page.api_keys.table.actions": "Chhau-chok",
"page.api_keys.table.created_at": "Kiàn-tì li̍t-kî",
"page.api_keys.table.description": "Biâu-su̍t",
"page.api_keys.table.last_used_at": "Siōng-bóe pái sú-iōng",
"page.api_keys.table.token": "Só-sî",
"page.api_keys.title": "API só-sî",
"page.categories_count": [
"%d ê lūi-pia̍t"
],
"page.categories.entries": "Siau-sit",
"page.categories.feed_count": [
"Ū %d ê Siau-sit lâi-goân"
],
"page.categories.feeds": "Siau-sit lâi-goân",
"page.categories.no_feed": "Ah-bô siau-sit lâi-goân",
"page.categories.title": "Lūi-pia̍t",
"page.category_label": "Lūi-pia̍t: %s",
"page.edit_category.title": "Pian-chi̍p lūi-pia̍t: %s",
"page.edit_feed.etag_header": "ETag piau-thâu:",
"page.edit_feed.last_check": "Siōng-bóe pái kiám-cha sî-kan",
"page.edit_feed.last_modified_header": "Siōng-bóe pái siu-kái piau-thâu:",
"page.edit_feed.last_parsing_error": "Siōng-bóe pái kái-sek m̄-tio̍h",
"page.edit_feed.no_header": "Bô",
"page.edit_feed.title": "Pian-chi̍p Siau-sit lâi-goân: %s",
"page.edit_user.title": "pian-chi̍p sú-iōng-lâng: %s",
"page.entry.attachments": "Hù-kiāⁿ",
"page.feeds.error_count": [
"%d ê m̄-tio̍h"
],
"page.feeds.last_check": "Siōng-bóe kiám-cha sî-kan:",
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
"page.feeds.title": "Siau-sit lâi-goân",
"page.history.title": "Kì-lo̍k",
"page.import.title": "Hōe-li̍p",
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
"page.integration.bookmarklet.help": "Lí ē-sái iōng chit ê te̍k-pia̍t ê chheh-chhiam ti̍t-chiap tēng bāng-ia̍h ê siau-sit",
"page.integration.bookmarklet.instructions": "Kā chit ê liân-kiat thoa khì iû-lám khì ê chheh-chhiam lân",
"page.integration.bookmarklet.name": "Siu-chông Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API thâu",
"page.integration.miniflux_api_password": "Bi̍t-bé",
"page.integration.miniflux_api_password_value": "Lí ê kháu-chō ê bi̍t-bé",
"page.integration.miniflux_api_username": "Kháu-chō miâ",
"page.integrations.title": "Chéng-ha̍p",
"page.keyboard_shortcuts.close_modal": "Kìm tiāu tùi-ōe thang",
"page.keyboard_shortcuts.download_content": "Liah goân-tóe ê siau-sit lōe-iông",
"page.keyboard_shortcuts.go_to_bottom_item": "Sóa khì thōng ē-kha ê siau-sit",
"page.keyboard_shortcuts.go_to_categories": "Phah khui lūi-pia̍t ia̍h",
"page.keyboard_shortcuts.go_to_feed": "Khì siau-sit lâi-goân",
"page.keyboard_shortcuts.go_to_feeds": "Phah khui siau-sit lâi-goân ia̍h",
"page.keyboard_shortcuts.go_to_history": "Phah khui kì-lo̍k ia̍h",
"page.keyboard_shortcuts.go_to_next_item": "Āu-chi̍t ê siau-sit",
"page.keyboard_shortcuts.go_to_next_page": "Āu-chi̍t ia̍h",
"page.keyboard_shortcuts.go_to_previous_item": "Téng-chi̍t ê siau-sit",
"page.keyboard_shortcuts.go_to_previous_page": "Téng-chi̍t ia̍h",
"page.keyboard_shortcuts.go_to_search": "Phah khui chhiau-chhē ia̍h",
"page.keyboard_shortcuts.go_to_settings": "Phah khui siat-tēng ia̍h",
"page.keyboard_shortcuts.go_to_starred": "Phah khui siu-chông--ê ia̍h",
"page.keyboard_shortcuts.go_to_top_item": "Sóa khì thōng téng-koân ê siau-sit",
"page.keyboard_shortcuts.go_to_unread": "Phah khui ah-bōe tha̍k--ê ia̍h",
"page.keyboard_shortcuts.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k--kè",
"page.keyboard_shortcuts.open_comments": "Phah khui hôe-èng liân-kiat",
"page.keyboard_shortcuts.open_comments_same_window": "Tī chit-má ê hun-ia̍h phah khui hôe-èng liân-kiat",
"page.keyboard_shortcuts.open_item": "Phah khui soán-te̍k ê siau-sit",
"page.keyboard_shortcuts.open_original": "Phah khui siau-sit goân-tóe ê liân-kiat",
"page.keyboard_shortcuts.open_original_same_window": "Tī chit-má ê hun-ia̍h phah khui siau-sit goân-tóe ê liân-kiat",
"page.keyboard_shortcuts.refresh_all_feeds": "Tī pōe-āu ōaⁿ-sin siau-sit lâi-goân",
"page.keyboard_shortcuts.remove_feed": "Thâi tiāu siau-sit lâi-goân",
"page.keyboard_shortcuts.save_article": "Pó-chûn siau-sit",
"page.keyboard_shortcuts.scroll_item_to_top": "Sóa khì bāng-ia̍h siōng téng-koân",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Hián-sī khoài-sok khí",
"page.keyboard_shortcuts.subtitle.actions": "Chhau-chok",
"page.keyboard_shortcuts.subtitle.items": "Bûn-chiong tō-lám",
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
"page.keyboard_shortcuts.title": "Khoài-sok khí",
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
"page.login.google_signin": "Sú-iōng Google teng-lo̍k",
"page.login.oidc_signin": "Sú-iōng %s teng-lo̍k",
"page.login.title": "teng-lo̍k",
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
"page.new_api_key.title": "Sin ê API só-sî",
"page.new_category.title": "Sin lūi-pia̍t",
"page.new_user.title": "Sin sú-iōng-lâng",
"page.offline.message": "Lí í-keng lî-sòaⁿ",
"page.offline.refresh_page": "Chhì-khòaⁿ-māi têng tha̍k bāng-ia̍h",
"page.offline.title": "Lî-sòaⁿ bô͘-sek",
"page.read_entry_count": [
"%d ê tha̍k kè ê siau-sit"
],
"page.search.title": "Chhiau-chhē kiat-kó",
"page.sessions.table.actions": "Chhau-chok",
"page.sessions.table.current_session": "Chit-má teng-lo̍k--ê",
"page.sessions.table.date": "Li̍t-kî",
"page.sessions.table.ip": "IP tōe-chí",
"page.sessions.table.user_agent": "Sú-iōng-lâng tāi-lí",
"page.sessions.title": "Ū teng-lo̍k--ê",
"page.settings.link_google_account": "Kah góa ê Google kháu-chō kiat chòe-hé",
"page.settings.link_oidc_account": "Kah góa ê %s kháu-chō kiat chòe-hé",
"page.settings.title": "Siat-tēng",
"page.settings.unlink_google_account": "Phah khui kah góa ê Google kháu-chō ê kiat",
"page.settings.unlink_oidc_account": "Phah khui kah góa ê %s kháu-chō ê kiat",
"page.settings.webauthn.actions": "Chhau-chok",
"page.settings.webauthn.added_on": "Sin cheng-ka ê sî-kan",
"page.settings.webauthn.delete": [
"Thâi tiāu %d ê Passkey"
],
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
"page.settings.webauthn.passkey_name": "Passkey miâ",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Chù-chheh Passkey",
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
"page.shared_entries_count": [
"Í-keng hun-hióng %d ê siau-sit"
],
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
"page.starred_entry_count": [
"%d ê siu-chông ê siau-sit"
],
"page.starred.title": "Siu-chông",
"page.total_entry_count": [
"Lóng-chóng %d ê siau-sit"
],
"page.unread_entry_count": [
"%d ê siau-sit ah-bōe tha̍k"
],
"page.unread.title": "Ah-bōe tha̍k",
"page.users.actions": "chhau-chok",
"page.users.admin.no": "Hóⁿ",
"page.users.admin.yes": "Sī",
"page.users.is_admin": "Koán-lí-lâng",
"page.users.last_login": "Siōng-bóe pái teng-lo̍k",
"page.users.never_logged": "Chū-lâi bô teng-lo̍k kè",
"page.users.title": "Sú-iōng-lâng",
"page.users.username": "Sú-iōng-lâng miâ",
"page.webauthn_rename.title": "Tiông-sin hō͘ miâ Passkey",
"pagination.first": "Thâu-chi̍t ia̍h",
"pagination.last": "Siōng-bóe ia̍h",
"pagination.next": "Āu-chi̍t ia̍h",
"pagination.previous": "Téng-chi̍t ia̍h",
"search.label": "Chhiau-chhē",
"search.placeholder": "Chhiau-chhē...",
"search.submit": "Chhiau-chhē",
"skip_to_content": "Sóa kah chú-iàu ê lōe-iông",
"time_elapsed.days": [
"%d kang chêng"
],
"time_elapsed.hours": [
"%d tiám-cheng chêng"
],
"time_elapsed.minutes": [
"%d hun-cheng chêng"
],
"time_elapsed.months": [
"%d kò ge̍h chêng"
],
"time_elapsed.not_yet": "ah-bōe",
"time_elapsed.now": "tú-chiah",
"time_elapsed.weeks": [
"%d lé-pài chêng"
],
"time_elapsed.years": [
"%d nî chêng"
],
"time_elapsed.yesterday": "cha-hng",
"tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
"tooltip.logged_user": "Chit-má teng-lo̍k--ê: %s"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -87,7 +87,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
}
}
output, err := doc.Find("body").First().Html()
output, err := doc.FindMatcher(goquery.Single("body")).Html()
if err != nil {
return htmlDocument
}
+2 -2
View File
@@ -24,7 +24,7 @@ type SessionData struct {
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
}
func (s SessionData) String() string {
func (s *SessionData) String() string {
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
s.CSRF,
s.OAuth2State,
@@ -40,7 +40,7 @@ func (s SessionData) String() string {
}
// Value converts the session data to JSON.
func (s SessionData) Value() (driver.Value, error) {
func (s *SessionData) Value() (driver.Value, error) {
j, err := json.Marshal(s)
return j, err
}
+28 -2
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"strings"
@@ -27,16 +28,41 @@ type EnclosureUpdateRequest struct {
}
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e Enclosure) Html5MimeType() string {
func (e *Enclosure) Html5MimeType() string {
if e.MimeType == "video/m4v" {
return "video/x-m4v"
}
return e.MimeType
}
func (e *Enclosure) IsAudio() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
}
func (e *Enclosure) IsVideo() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
}
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
mediaURL := strings.ToLower(e.URL)
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
return enclosure
}
}
return nil
}
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
@@ -49,7 +75,7 @@ func (el EnclosureList) ContainsAudioOrVideo() bool {
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "all" || proxyOption != "none" {
if proxyOption != "none" {
for i := range el {
if urllib.IsHTTPS(el[i].URL) {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
+5 -2
View File
@@ -53,8 +53,11 @@ type Feed struct {
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
PushoverEnabled bool `json:"pushover_enabled,omitempty"`
PushoverPriority int `json:"pushover_priority,omitempty"`
// Non-persisted attributes
Category *Category `json:"category,omitempty"`
@@ -123,8 +126,8 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
}
}
+10
View File
@@ -104,6 +104,16 @@ type Integration struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
NtfyInternalLinks bool
CuboxEnabled bool
CuboxAPILink string
DiscordEnabled bool
DiscordWebhookLink string
SlackEnabled bool
SlackWebhookLink string
PushoverEnabled bool
PushoverUser string
PushoverToken string
PushoverDevice string
PushoverPrefix string
}
+2 -6
View File
@@ -6,7 +6,6 @@ package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"crypto/sha256"
"encoding/base64"
"io"
"golang.org/x/oauth2"
@@ -33,17 +32,14 @@ func (u *Authorization) CodeVerifier() string {
func GenerateAuthorization(config *oauth2.Config) *Authorization {
codeVerifier := crypto.GenerateRandomStringHex(32)
sha2 := sha256.New()
io.WriteString(sha2, codeVerifier)
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
sum := sha256.Sum256([]byte(codeVerifier))
state := crypto.GenerateRandomStringHex(24)
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])),
)
return &Authorization{
-2
View File
@@ -9,7 +9,6 @@ import (
"strings"
"miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer"
)
// The "atom:feed" element is the document (i.e., top-level) element of
@@ -188,7 +187,6 @@ func (a *Atom10Text) Title() string {
content = a.CharData
}
content = sanitizer.StripTags(content)
return strings.TrimSpace(content)
}
+15 -3
View File
@@ -492,6 +492,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
</title>
<link href="http://example.org/c"/>
</entry>
<entry>
<title type="html"><![CDATA[Test with self-closing &lt;tag&gt;]]></title>
<link href="http://example.org/d"/>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
@@ -499,7 +503,11 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != "Code Test" {
if len(feed.Entries) != 4 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Title != "<code>Code</code> Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
@@ -510,6 +518,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
if feed.Entries[2].Title != "Entry title with space around CDATA" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[2].Title)
}
if feed.Entries[3].Title != "Test with self-closing <tag>" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[3].Title)
}
}
func TestParseEntryWithXHTMLTitle(t *testing.T) {
@@ -537,7 +549,7 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != `This is XHTML content.` {
if feed.Entries[0].Title != `This is <b>XHTML</b> content.` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
@@ -643,7 +655,7 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != `'AT&T'` {
if feed.Entries[0].Title != `&#39;AT&amp;T&#39;` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
+1 -3
View File
@@ -354,9 +354,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Workaround for dates that don't use GMT.
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
loc, _ = time.LoadLocation("America/Los_Angeles")
}
if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
loc, _ = time.LoadLocation("America/New_York")
}
+27 -1
View File
@@ -5,6 +5,7 @@ package encoding // import "miniflux.app/v2/internal/reader/encoding"
import (
"bytes"
"fmt"
"io"
"unicode/utf8"
@@ -23,7 +24,11 @@ import (
// - Feeds with encoding specified only in XML document and not in HTTP header
// - Feeds with wrong encoding defined and already in UTF-8
func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
buffer, _ := io.ReadAll(input)
buffer, err := io.ReadAll(input)
if err != nil {
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
}
r := bytes.NewReader(buffer)
// The document is already UTF-8, do not do anything (avoid double-encoding).
@@ -35,3 +40,24 @@ 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)
}
// NewCharsetReader returns an io.Reader that converts the content of r to UTF-8.
func NewCharsetReader(r io.Reader, contentType string) (io.Reader, error) {
buffer, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
}
internalReader := bytes.NewReader(buffer)
// The document is already UTF-8, do not do anything.
if utf8.Valid(buffer) {
return internalReader, nil
}
// Transform document to UTF-8 from the specified encoding in Content-Type header.
// Note that only the first 1024 bytes are used to detect the encoding.
// If the <meta charset> tag is not found in the first 1024 bytes, charset.DetermineEncoding returns "windows-1252" resulting in encoding issues.
// See https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding
return charset.NewReader(internalReader, contentType)
}
+348
View File
@@ -0,0 +1,348 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package encoding // import "miniflux.app/v2/internal/reader/encoding"
import (
"bytes"
"io"
"os"
"testing"
"unicode/utf8"
)
func TestCharsetReaderWithUTF8(t *testing.T) {
file := "testdata/utf8.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("UTF-8", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithISO88591(t *testing.T) {
file := "testdata/iso-8859-1.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("ISO-8859-1", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithWindows1252(t *testing.T) {
file := "testdata/windows-1252.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("windows-1252", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Euro €"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithInvalidProlog(t *testing.T) {
file := "testdata/invalid-prolog.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("invalid", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithUTF8DocumentWithIncorrectProlog(t *testing.T) {
file := "testdata/utf8-incorrect-prolog.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("ISO-8859-1", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithWindows1252DocumentWithIncorrectProlog(t *testing.T) {
file := "testdata/windows-1252-incorrect-prolog.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("windows-1252", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Euro €"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithUTF8Document(t *testing.T) {
file := "testdata/utf8.html"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "text/html; charset=UTF-8")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithUTF8DocumentAndNoContentEncoding(t *testing.T) {
file := "testdata/utf8.html"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "text/html")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithISO88591Document(t *testing.T) {
file := "testdata/iso-8859-1.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "text/html; charset=ISO-8859-1")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithISO88591DocumentAndNoContentType(t *testing.T) {
file := "testdata/iso-8859-1.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithISO88591DocumentWithMetaAfter1024Bytes(t *testing.T) {
file := "testdata/iso-8859-1-meta-after-1024.html"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "text/html")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {
file := "testdata/utf8-meta-after-1024.html"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "text/html")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
expectedUnicodeString := "Café"
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="invalid"?>
<feed>
<title>테스트 피드</title>
<entry>
<title>Café</title>
</entry>
</feed>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<!---
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
This comment is used to pad the file to 1024 bytes.
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
---
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
-->
<head>
<meta charset="iso-8859-1">
<title>Frédéric</title>
</head>
<body>
<p>Café</p>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="iso-8859-1">
<title>Frédéric</title>
</head>
<body>
<p>Café</p>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="ISO-8859-1"?>
<rss version="2.0">
<channel>
<title>Café</title>
<description>Présentation</description>
</channel>
</rss>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="ISO-8859-1"?>
<feed>
<title>테스트 피드</title>
<entry>
<title>Café</title>
</entry>
</feed>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<!---
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
This comment is used to pad the file to 1024 bytes.
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
---
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
More text to pad the file to 1024 bytes.
-->
<head>
<meta charset="utf-8">
<title>Frédéric</title>
</head>
<body>
<p>Café</p>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Café</title>
</head>
<body>
<p>Café</p>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<feed>
<title>테스트 피드</title>
<entry>
<title>Café</title>
</entry>
</feed>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="windows-1252"?>
<rss version="2.0">
<channel>
<title>Euro €</title>
</channel>
</rss>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="windows-1252"?>
<rss version="2.0">
<channel>
<title>Euro €</title>
</channel>
</rss>
@@ -89,6 +89,15 @@ func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bo
return true
}
func (r *ResponseHandler) IsRedirect() bool {
return r.httpResponse != nil &&
(r.httpResponse.StatusCode == http.StatusMovedPermanently ||
r.httpResponse.StatusCode == http.StatusFound ||
r.httpResponse.StatusCode == http.StatusSeeOther ||
r.httpResponse.StatusCode == http.StatusTemporaryRedirect ||
r.httpResponse.StatusCode == http.StatusPermanentRedirect)
}
func (r *ResponseHandler) Close() {
if r.httpResponse != nil && r.httpResponse.Body != nil && r.clientErr == nil {
r.httpResponse.Body.Close()
+24 -18
View File
@@ -31,11 +31,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
slog.String("feed_url", feedCreationRequest.FeedURL),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
}
@@ -71,7 +66,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
subscription.WithCategoryID(feedCreationRequest.CategoryID)
subscription.CheckedNow()
processor.ProcessFeedEntries(store, subscription, user, true)
processor.ProcessFeedEntries(store, subscription, userID, true)
if storeErr := store.CreateFeed(subscription); storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
@@ -105,11 +100,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
slog.String("feed_url", feedCreationRequest.FeedURL),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
}
@@ -170,7 +160,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.WithCategoryID(feedCreationRequest.CategoryID)
subscription.CheckedNow()
processor.ProcessFeedEntries(store, subscription, user, true)
processor.ProcessFeedEntries(store, subscription, userID, true)
if storeErr := store.CreateFeed(subscription); storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
@@ -195,11 +185,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.Bool("force_refresh", forceRefresh),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed, storeErr := store.FeedByID(userID, feedID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
@@ -256,6 +241,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -263,6 +252,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -289,6 +282,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
}
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
@@ -309,13 +306,17 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
)
originalFeed.Entries = updatedFeed.Entries
processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh)
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
updateExistingEntries := forceRefresh || !originalFeed.Crawler
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
if storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -335,6 +336,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.EtagHeader = responseHandler.ETag()
originalFeed.LastModifiedHeader = responseHandler.LastModified()
originalFeed.IconURL = updatedFeed.IconURL
iconChecker := icon.NewIconChecker(store, originalFeed)
if forceRefresh {
iconChecker.UpdateOrCreateFeedIcon()
@@ -358,6 +360,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
+5 -5
View File
@@ -34,32 +34,32 @@ func (c *IconChecker) fetchAndStoreIcon() {
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
iconFinder := NewIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
if icon, err := iconFinder.FindIcon(); err != nil {
slog.Debug("Unable to find feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else if icon == nil {
slog.Debug("No icon found",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
)
} else {
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
slog.Error("Unable to store feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else {
slog.Debug("Feed icon stored",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Int64("icon_id", icon.ID),
slog.String("icon_hash", icon.Hash),
+59 -2
View File
@@ -4,22 +4,29 @@
package icon // import "miniflux.app/v2/internal/reader/icon"
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log/slog"
"net/url"
"regexp"
"slices"
"strings"
"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"
"golang.org/x/image/draw"
)
type IconFinder struct {
@@ -180,9 +187,59 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
Content: responseBody,
}
icon = resizeIcon(icon)
return icon, nil
}
func resizeIcon(icon *model.Icon) *model.Icon {
r := bytes.NewReader(icon.Content)
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif"}, icon.MimeType) {
slog.Info("icon isn't a png/gif/jpeg/ico, can't resize", slog.String("mimetype", icon.MimeType))
return icon
}
// Don't resize icons that we can't decode, or that already have the right size.
config, _, err := image.DecodeConfig(r)
if err != nil {
slog.Warn("unable to decode the metadata of the icon", slog.Any("error", err))
return icon
}
if config.Height <= 32 && config.Width <= 32 {
slog.Debug("icon don't need to be rescaled", slog.Int("height", config.Height), slog.Int("width", config.Width))
return icon
}
r.Seek(0, io.SeekStart)
var src image.Image
switch icon.MimeType {
case "image/jpeg":
src, err = jpeg.Decode(r)
case "image/png":
src, err = png.Decode(r)
case "image/gif":
src, err = gif.Decode(r)
}
if err != nil || src == nil {
slog.Warn("unable to decode the icon", slog.Any("error", err))
return icon
}
dst := image.NewRGBA(image.Rect(0, 0, 32, 32))
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
var b bytes.Buffer
if err = png.Encode(io.Writer(&b), dst); err != nil {
slog.Warn("unable to encode the new icon", slog.Any("error", err))
}
icon.Content = b.Bytes()
icon.MimeType = "image/png"
return icon
}
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
queries := []string{
"link[rel='icon' i]",
@@ -191,7 +248,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
"link[rel='apple-touch-icon-precomposed.png']",
}
htmlDocumentReader, err := charset.NewReader(body, contentType)
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
if err != nil {
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
}
+54
View File
@@ -4,8 +4,13 @@
package icon // import "miniflux.app/v2/internal/reader/icon"
import (
"bytes"
"encoding/base64"
"image"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestParseImageDataURL(t *testing.T) {
@@ -125,3 +130,52 @@ func TestParseDocumentWithWhitespaceIconURL(t *testing.T) {
t.Errorf(`Invalid icon URL, got %q`, iconURLs[0])
}
}
func TestResizeIconSmallGif(t *testing.T) {
data, err := base64.StdEncoding.DecodeString("R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")
if err != nil {
t.Fatal(err)
}
icon := model.Icon{
Content: data,
MimeType: "image/gif",
}
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
t.Fatalf("Converted gif smaller than 16x16")
}
}
func TestResizeIconPng(t *testing.T) {
data, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAALUlEQVR42u3OMQEAAAgDoJnc6BpjDyRgcrcpGwkJCQkJCQkJCQkJCQkJCYmyB7NfUj/Kk4FkAAAAAElFTkSuQmCC")
if err != nil {
t.Fatal(err)
}
icon := model.Icon{
Content: data,
MimeType: "image/png",
}
resizedIcon := resizeIcon(&icon)
if bytes.Equal(data, resizedIcon.Content) {
t.Fatalf("Didn't convert png of 33x33")
}
config, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content))
if err != nil {
t.Fatalf("Couln't decode resulting png: %v", err)
}
if config.Height != 32 || config.Width != 32 {
t.Fatalf("Was expecting an image of 16x16, got %dx%d", config.Width, config.Height)
}
}
func TestResizeInvalidImage(t *testing.T) {
icon := model.Icon{
Content: []byte("invalid data"),
MimeType: "image/gif",
}
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
t.Fatalf("Tried to convert an invalid image")
}
}
+6 -6
View File
@@ -35,11 +35,11 @@ type opmlOutline struct {
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
}
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type opmlOutlineXml opmlOutline
outlineType := ""
if outline.IsSubscription() {
if o.IsSubscription() {
outlineType = "rss"
}
@@ -47,16 +47,16 @@ func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) er
opmlOutlineXml
Type string `xml:"type,attr,omitempty"`
}{
opmlOutlineXml: opmlOutlineXml(outline),
opmlOutlineXml: opmlOutlineXml(o),
Type: outlineType,
}, start)
}
func (o *opmlOutline) IsSubscription() bool {
func (o opmlOutline) IsSubscription() bool {
return strings.TrimSpace(o.FeedURL) != ""
}
func (o *opmlOutline) GetTitle() string {
func (o opmlOutline) GetTitle() string {
if o.Title != "" {
return o.Title
}
@@ -76,7 +76,7 @@ func (o *opmlOutline) GetTitle() string {
return ""
}
func (o *opmlOutline) GetSiteURL() string {
func (o opmlOutline) GetSiteURL() string {
if o.SiteURL != "" {
return o.SiteURL
}
+1 -1
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"encoding/json"
+200
View File
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"log/slog"
"regexp"
"slices"
"strings"
"time"
"miniflux.app/v2/internal/model"
)
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
if user.BlockFilterEntryRules != "" {
rules := strings.Split(user.BlockFilterEntryRules, "\n")
for _, rule := range rules {
parts := strings.SplitN(rule, "=", 2)
var match bool
switch parts[0] {
case "EntryDate":
datePattern := parts[1]
match = isDateMatchingPattern(entry.Date, datePattern)
case "EntryTitle":
match, _ = regexp.MatchString(parts[1], entry.Title)
case "EntryURL":
match, _ = regexp.MatchString(parts[1], entry.URL)
case "EntryCommentsURL":
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
case "EntryContent":
match, _ = regexp.MatchString(parts[1], entry.Content)
case "EntryAuthor":
match, _ = regexp.MatchString(parts[1], entry.Author)
case "EntryTag":
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
match, _ = regexp.MatchString(parts[1], tag)
return match
})
if containsTag {
match = true
}
}
if match {
slog.Debug("Blocking entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", rule),
)
return true
}
}
}
if feed.BlocklistRules == "" {
return false
}
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
if err != nil {
slog.Debug("Failed on regexp compilation",
slog.String("pattern", feed.BlocklistRules),
slog.Any("error", err),
)
return false
}
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return compiledBlocklist.MatchString(tag)
})
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
slog.Debug("Blocking entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.BlocklistRules),
)
return true
}
return false
}
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
if user.KeepFilterEntryRules != "" {
rules := strings.Split(user.KeepFilterEntryRules, "\n")
for _, rule := range rules {
parts := strings.SplitN(rule, "=", 2)
var match bool
switch parts[0] {
case "EntryDate":
datePattern := parts[1]
match = isDateMatchingPattern(entry.Date, datePattern)
case "EntryTitle":
match, _ = regexp.MatchString(parts[1], entry.Title)
case "EntryURL":
match, _ = regexp.MatchString(parts[1], entry.URL)
case "EntryCommentsURL":
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
case "EntryContent":
match, _ = regexp.MatchString(parts[1], entry.Content)
case "EntryAuthor":
match, _ = regexp.MatchString(parts[1], entry.Author)
case "EntryTag":
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
match, _ = regexp.MatchString(parts[1], tag)
return match
})
if containsTag {
match = true
}
}
if match {
slog.Debug("Allowing entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", rule),
)
return true
}
}
return false
}
if feed.KeeplistRules == "" {
return true
}
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
if err != nil {
slog.Debug("Failed on regexp compilation",
slog.String("pattern", feed.KeeplistRules),
slog.Any("error", err),
)
return false
}
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return compiledKeeplist.MatchString(tag)
})
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
slog.Debug("Allow entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.KeeplistRules),
)
return true
}
return false
}
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
if pattern == "future" {
return entryDate.After(time.Now())
}
parts := strings.SplitN(pattern, ":", 2)
if len(parts) != 2 {
return false
}
operator := parts[0]
dateStr := parts[1]
switch operator {
case "before":
targetDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return false
}
return entryDate.Before(targetDate)
case "after":
targetDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return false
}
return entryDate.After(targetDate)
case "between":
dates := strings.Split(dateStr, ",")
if len(dates) != 2 {
return false
}
startDate, err1 := time.Parse("2006-01-02", dates[0])
endDate, err2 := time.Parse("2006-01-02", dates[1])
if err1 != nil || err2 != nil {
return false
}
return entryDate.After(startDate) && entryDate.Before(endDate)
}
return false
}
+10 -7
View File
@@ -1,13 +1,13 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"errors"
"fmt"
"log/slog"
"regexp"
"net/url"
"strconv"
"github.com/PuerkitoBio/goquery"
@@ -17,14 +17,17 @@ import (
"miniflux.app/v2/internal/reader/fetcher"
)
var nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchNebulaWatchTime() {
return false
}
matches := nebulaRegex.FindStringSubmatch(entry.URL)
return matches != nil
u, err := url.Parse(entry.URL)
if err != nil {
return false
}
return u.Hostname() == "nebula.tv"
}
func fetchNebulaWatchTime(websiteURL string) (int, error) {
@@ -45,7 +48,7 @@ func fetchNebulaWatchTime(websiteURL string) (int, error) {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="video:duration"]`)).Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
+10 -7
View File
@@ -1,13 +1,13 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"errors"
"fmt"
"log/slog"
"regexp"
"net/url"
"strconv"
"github.com/PuerkitoBio/goquery"
@@ -17,14 +17,17 @@ import (
"miniflux.app/v2/internal/reader/fetcher"
)
var odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchOdyseeWatchTime() {
return false
}
matches := odyseeRegex.FindStringSubmatch(entry.URL)
return matches != nil
u, err := url.Parse(entry.URL)
if err != nil {
return false
}
return u.Hostname() == "odysee.com"
}
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
@@ -45,7 +48,7 @@ func fetchOdyseeWatchTime(websiteURL string) (int, error) {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="og:video:duration"]`)).Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
+17 -235
View File
@@ -1,15 +1,16 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"log/slog"
"regexp"
"slices"
"strings"
"time"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model"
@@ -20,17 +21,20 @@ import (
"miniflux.app/v2/internal/reader/scraper"
"miniflux.app/v2/internal/reader/urlcleaner"
"miniflux.app/v2/internal/storage"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
)
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
// ProcessFeedEntries downloads original web page for entries and apply filters.
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64, forceRefresh bool) {
var filteredEntries model.Entries
user, storeErr := store.UserByID(userID)
if storeErr != nil {
slog.Error("Database error", slog.Any("error", storeErr))
return
}
// Process older entries first
for i := len(feed.Entries) - 1; i >= 0; i-- {
entry := feed.Entries[i]
@@ -121,151 +125,17 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
filteredEntries = append(filteredEntries, entry)
}
if user.ShowReadingTime && shouldFetchYouTubeWatchTimeInBulk() {
fetchYouTubeWatchTimeInBulk(filteredEntries)
}
feed.Entries = filteredEntries
}
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
if user.BlockFilterEntryRules != "" {
rules := strings.Split(user.BlockFilterEntryRules, "\n")
for _, rule := range rules {
parts := strings.SplitN(rule, "=", 2)
var match bool
switch parts[0] {
case "EntryTitle":
match, _ = regexp.MatchString(parts[1], entry.Title)
case "EntryURL":
match, _ = regexp.MatchString(parts[1], entry.URL)
case "EntryCommentsURL":
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
case "EntryContent":
match, _ = regexp.MatchString(parts[1], entry.Content)
case "EntryAuthor":
match, _ = regexp.MatchString(parts[1], entry.Author)
case "EntryTag":
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
match, _ = regexp.MatchString(parts[1], tag)
return match
})
if containsTag {
match = true
}
}
if match {
slog.Debug("Blocking entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", rule),
)
return true
}
}
}
if feed.BlocklistRules == "" {
return false
}
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
if err != nil {
slog.Debug("Failed on regexp compilation",
slog.String("pattern", feed.BlocklistRules),
slog.Any("error", err),
)
return false
}
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return compiledBlocklist.MatchString(tag)
})
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
slog.Debug("Blocking entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.BlocklistRules),
)
return true
}
return false
}
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
if user.KeepFilterEntryRules != "" {
rules := strings.Split(user.KeepFilterEntryRules, "\n")
for _, rule := range rules {
parts := strings.SplitN(rule, "=", 2)
var match bool
switch parts[0] {
case "EntryTitle":
match, _ = regexp.MatchString(parts[1], entry.Title)
case "EntryURL":
match, _ = regexp.MatchString(parts[1], entry.URL)
case "EntryCommentsURL":
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
case "EntryContent":
match, _ = regexp.MatchString(parts[1], entry.Content)
case "EntryAuthor":
match, _ = regexp.MatchString(parts[1], entry.Author)
case "EntryTag":
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
match, _ = regexp.MatchString(parts[1], tag)
return match
})
if containsTag {
match = true
}
}
if match {
slog.Debug("Allowing entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", rule),
)
return true
}
}
return false
}
if feed.KeeplistRules == "" {
return true
}
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
if err != nil {
slog.Debug("Failed on regexp compilation",
slog.String("pattern", feed.KeeplistRules),
slog.Any("error", err),
)
return false
}
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
return compiledKeeplist.MatchString(tag)
})
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
slog.Debug("Allow entry based on rule",
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("rule", feed.KeeplistRules),
)
return true
}
return false
}
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
startTime := time.Now()
@@ -346,94 +216,6 @@ func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
return rewrittenURL
}
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
if !user.ShowReadingTime {
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
return
}
if shouldFetchYouTubeWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch YouTube watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
if shouldFetchNebulaWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchNebulaWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch Nebula watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
if shouldFetchOdyseeWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchOdyseeWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch Odysee watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
if shouldFetchBilibiliWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchBilibiliWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch Bilibili watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
// Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
}
func isRecentEntry(entry *model.Entry) bool {
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
return true
@@ -75,6 +75,12 @@ func TestAllowEntries(t *testing.T) {
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(-24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, false},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=after:2024-03-15"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, true},
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, false},
}
for _, tc := range scenarios {
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"log/slog"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/storage"
)
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
if !user.ShowReadingTime {
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
return
}
// Define a type for watch time fetching functions
type watchTimeFetcher func(string) (int, error)
// Define watch time fetching scenarios
watchTimeScenarios := []struct {
shouldFetch func(*model.Entry) bool
fetchFunc watchTimeFetcher
platform string
}{
{shouldFetchYouTubeWatchTimeForSingleEntry, fetchYouTubeWatchTimeForSingleEntry, "YouTube"},
{shouldFetchNebulaWatchTime, fetchNebulaWatchTime, "Nebula"},
{shouldFetchOdyseeWatchTime, fetchOdyseeWatchTime, "Odysee"},
{shouldFetchBilibiliWatchTime, fetchBilibiliWatchTime, "Bilibili"},
}
// Iterate through scenarios and attempt to fetch watch time
for _, scenario := range watchTimeScenarios {
if scenario.shouldFetch(entry) {
if entryIsNew {
if watchTime, err := scenario.fetchFunc(entry.URL); err != nil {
slog.Warn("Unable to fetch watch time",
slog.String("platform", scenario.platform),
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
} else {
entry.ReadingTime = watchTime
}
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
break
}
}
// Fallback to text-based reading time estimation
if entry.ReadingTime == 0 && entry.Content != "" {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
}
+124 -19
View File
@@ -1,14 +1,17 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
package processor // import "miniflux.app/v2/internal/reader/processor
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
@@ -23,16 +26,30 @@ var (
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
)
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchYouTubeWatchTime() {
return false
}
matches := youtubeRegex.FindStringSubmatch(entry.URL)
urlMatchesYouTubePattern := len(matches) == 2
return urlMatchesYouTubePattern
func isYouTubeVideoURL(websiteURL string) bool {
return len(youtubeRegex.FindStringSubmatch(websiteURL)) == 2
}
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
func getVideoIDFromYouTubeURL(websiteURL string) string {
parsedWebsiteURL, err := url.Parse(websiteURL)
if err != nil {
return ""
}
return parsedWebsiteURL.Query().Get("v")
}
func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
}
func shouldFetchYouTubeWatchTimeInBulk() bool {
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
}
func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
slog.Debug("Fetching YouTube watch time for a single entry", slog.String("website_url", websiteURL))
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
@@ -50,17 +67,96 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
return 0, docErr
}
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
htmlDuration, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
if !exists {
return 0, errors.New("duration has not found")
return 0, errors.New("youtube: duration has not found")
}
dur, err := parseISO8601(durs)
parsedDuration, err := parseISO8601(htmlDuration)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
return 0, fmt.Errorf("youtube: unable to parse duration %s: %v", htmlDuration, err)
}
return int(dur.Minutes()), nil
return int(parsedDuration.Minutes()), nil
}
func fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {
var videosEntriesMapping = make(map[string]*model.Entry)
var videoIDs []string
for _, entry := range entries {
if !isYouTubeVideoURL(entry.URL) {
continue
}
youtubeVideoID := getVideoIDFromYouTubeURL(entry.URL)
if youtubeVideoID == "" {
continue
}
videosEntriesMapping[getVideoIDFromYouTubeURL(entry.URL)] = entry
videoIDs = append(videoIDs, youtubeVideoID)
}
if len(videoIDs) == 0 {
return
}
watchTimeMap, err := fetchYouTubeWatchTimeFromApiInBulk(videoIDs)
if err != nil {
slog.Warn("Unable to fetch YouTube watch time in bulk", slog.Any("error", err))
return
}
for videoID, watchTime := range watchTimeMap {
if entry, ok := videosEntriesMapping[videoID]; ok {
entry.ReadingTime = int(watchTime.Minutes())
}
}
}
func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Duration, error) {
slog.Debug("Fetching YouTube watch time in bulk", slog.Any("video_ids", videoIDs))
apiQuery := url.Values{}
apiQuery.Set("id", strings.Join(videoIDs, ","))
apiQuery.Set("key", config.Opts.YouTubeApiKey())
apiQuery.Set("part", "contentDetails")
apiURL := url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "youtube/v3/videos",
RawQuery: apiQuery.Encode(),
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.Any("error", localizedError.Error()))
return nil, localizedError.Error()
}
var videos youtubeVideoListResponse
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
return nil, fmt.Errorf("youtube: unable to decode JSON: %v", err)
}
watchTimeMap := make(map[string]time.Duration)
for _, video := range videos.Items {
duration, err := parseISO8601(video.ContentDetails.Duration)
if err != nil {
slog.Warn("Unable to parse ISO8601 duration", slog.Any("error", err))
continue
}
watchTimeMap[video.ID] = duration
}
return watchTimeMap, nil
}
func parseISO8601(from string) (time.Duration, error) {
@@ -70,7 +166,7 @@ func parseISO8601(from string) (time.Duration, error) {
if iso8601Regex.MatchString(from) {
match = iso8601Regex.FindStringSubmatch(from)
} else {
return 0, errors.New("could not parse duration string")
return 0, errors.New("youtube: could not parse duration string")
}
for i, name := range iso8601Regex.SubexpNames() {
@@ -86,15 +182,24 @@ func parseISO8601(from string) (time.Duration, error) {
switch name {
case "hour":
d += (time.Duration(val) * time.Hour)
d += time.Duration(val) * time.Hour
case "minute":
d += (time.Duration(val) * time.Minute)
d += time.Duration(val) * time.Minute
case "second":
d += (time.Duration(val) * time.Second)
d += time.Duration(val) * time.Second
default:
return 0, fmt.Errorf("unknown field %s", name)
return 0, fmt.Errorf("youtube: unknown field %s", name)
}
}
return d, nil
}
type youtubeVideoListResponse struct {
Items []struct {
ID string `json:"id"`
ContentDetails struct {
Duration string `json:"duration"`
} `json:"contentDetails"`
} `json:"items"`
}
+34
View File
@@ -36,3 +36,37 @@ func TestParseISO8601(t *testing.T) {
}
}
}
func TestGetYouTubeVideoIDFromURL(t *testing.T) {
scenarios := []struct {
url string
expected string
}{
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", "HLrqNhgdiC0"},
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", "HLrqNhgdiC0"},
{"https://example.org/test", ""},
}
for _, tc := range scenarios {
result := getVideoIDFromYouTubeURL(tc.url)
if tc.expected != result {
t.Errorf(`Unexpected result, got %q for url %q`, result, tc.url)
}
}
}
func TestIsYouTubeVideoURL(t *testing.T) {
scenarios := []struct {
url string
expected bool
}{
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", true},
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", true},
{"https://example.org/test", false},
}
for _, tc := range scenarios {
result := isYouTubeVideoURL(tc.url)
if tc.expected != result {
t.Errorf(`Unexpected result, got %v for url %q`, result, tc.url)
}
}
}
+47 -43
View File
@@ -4,11 +4,9 @@
package readability // import "miniflux.app/v2/internal/reader/readability"
import (
"bytes"
"fmt"
"io"
"log/slog"
"math"
"regexp"
"strings"
@@ -24,14 +22,12 @@ const (
var (
divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
sentenceRegexp = regexp.MustCompile(`\.( |$)`)
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
okMaybeItsACandidateRegexp = regexp.MustCompile(`and|article|body|column|main|shadow`)
unlikelyCandidatesRegexp = regexp.MustCompile(`banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
negativeRegexp = regexp.MustCompile(`hid|banner|combx|comment|com-|contact|foot|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby`)
positiveRegexp = regexp.MustCompile(`article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
)
type candidate struct {
@@ -77,16 +73,14 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
return "", "", err
}
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
hrefValue = strings.TrimSpace(hrefValue)
if urllib.IsAbsoluteURL(hrefValue) {
baseURL = hrefValue
}
}
document.Find("script,style").Each(func(i int, s *goquery.Selection) {
removeNodes(s)
})
document.Find("script,style").Remove()
transformMisusedDivsIntoParagraphs(document)
removeUnlikelyCandidates(document)
@@ -107,8 +101,9 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
// Now that we have the top candidate, look through its siblings for content that might also be related.
// Things like preambles, content split by ads that we removed, etc.
func getArticle(topCandidate *candidate, candidates candidateList) string {
output := bytes.NewBufferString("<div>")
siblingScoreThreshold := float32(math.Max(10, float64(topCandidate.score*.2)))
var output strings.Builder
output.WriteString("<div>")
siblingScoreThreshold := max(10, topCandidate.score*.2)
topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
append := false
@@ -125,10 +120,14 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
content := s.Text()
contentLength := len(content)
if contentLength >= 80 && linkDensity < .25 {
append = true
} else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
append = true
if contentLength >= 80 {
if linkDensity < .25 {
append = true
}
} else {
if linkDensity == 0 && containsSentence(content) {
append = true
}
}
}
@@ -139,7 +138,7 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
}
html, _ := s.Html()
fmt.Fprintf(output, "<%s>%s</%s>", tag, html, tag)
output.WriteString("<" + tag + ">" + html + "</" + tag + ">")
}
})
@@ -148,16 +147,29 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
}
func removeUnlikelyCandidates(document *goquery.Document) {
var shouldRemove = func(str string) bool {
str = strings.ToLower(str)
if strings.Contains(str, "popupbody") || strings.Contains(str, "-ad") || strings.Contains(str, "g-plus") {
return true
} else if unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str) {
return true
}
return false
}
document.Find("*").Each(func(i int, s *goquery.Selection) {
if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
return
}
class, _ := s.Attr("class")
id, _ := s.Attr("id")
str := class + id
if blacklistCandidatesRegexp.MatchString(str) || (unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str)) {
removeNodes(s)
if class, ok := s.Attr("class"); ok {
if shouldRemove(class) {
s.Remove()
}
} else if id, ok := s.Attr("id"); ok {
if shouldRemove(id) {
s.Remove()
}
}
})
}
@@ -221,7 +233,7 @@ func getCandidates(document *goquery.Document) candidateList {
contentScore += float32(strings.Count(text, ",") + 1)
// For every 100 characters in this paragraph, add another point. Up to 3 points.
contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
contentScore += float32(min(len(text)/100.0, 3))
candidates[parentNode].score += contentScore
if grandParentNode != nil {
@@ -260,13 +272,14 @@ func scoreNode(s *goquery.Selection) *candidate {
// Get the density of links as a percentage of the content
// This is the amount of text that is inside a link divided by the total text in the node.
func getLinkDensity(s *goquery.Selection) float32 {
linkLength := len(s.Find("a").Text())
textLength := len(s.Text())
if textLength == 0 {
return 0
}
linkLength := len(s.Find("a").Text())
return float32(linkLength) / float32(textLength)
}
@@ -274,25 +287,21 @@ func getLinkDensity(s *goquery.Selection) float32 {
// element looks good or bad.
func getClassWeight(s *goquery.Selection) float32 {
weight := 0
class, _ := s.Attr("class")
id, _ := s.Attr("id")
if class != "" {
if class, ok := s.Attr("class"); ok {
class = strings.ToLower(class)
if negativeRegexp.MatchString(class) {
weight -= 25
}
if positiveRegexp.MatchString(class) {
} else if positiveRegexp.MatchString(class) {
weight += 25
}
}
if id != "" {
if id, ok := s.Attr("id"); ok {
id = strings.ToLower(id)
if negativeRegexp.MatchString(id) {
weight -= 25
}
if positiveRegexp.MatchString(id) {
} else if positiveRegexp.MatchString(id) {
weight += 25
}
}
@@ -310,11 +319,6 @@ func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
})
}
func removeNodes(s *goquery.Selection) {
s.Each(func(i int, s *goquery.Selection) {
parent := s.Parent()
if parent.Length() > 0 {
parent.Get(0).RemoveChild(s.Get(0))
}
})
func containsSentence(content string) bool {
return strings.HasSuffix(content, ".") || strings.Contains(content, ". ")
}
@@ -4,6 +4,8 @@
package readability // import "miniflux.app/v2/internal/reader/readability"
import (
"bytes"
"os"
"strings"
"testing"
)
@@ -100,3 +102,83 @@ func TestWithoutBaseURL(t *testing.T) {
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
}
}
func TestRemoveStyleScript(t *testing.T) {
html := `
<html>
<head>
<title>Test</title>
<script src="tololo.js"></script>
</head>
<body>
<script src="tololo.js"></script>
<style>
h1 {color:red;}
p {color:blue;}
</style>
<article>Some content</article>
</body>
</html>`
want := `<div><div><article>Somecontent</article></div></div>`
_, content, err := ExtractContent(strings.NewReader(html))
if err != nil {
t.Fatal(err)
}
content = strings.ReplaceAll(content, "\n", "")
content = strings.ReplaceAll(content, " ", "")
content = strings.ReplaceAll(content, "\t", "")
if content != want {
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
}
}
func TestRemoveBlacklist(t *testing.T) {
html := `
<html>
<head>
<title>Test</title>
</head>
<body>
<article class="super-ad">Some content</article>
<article class="g-plus-crap">Some other thing</article>
<article class="stuff popupbody">And more</article>
<article class="legit">Valid!</article>
</body>
</html>`
want := `<div><div><articleclass="legit">Valid!</article></div></div>`
_, content, err := ExtractContent(strings.NewReader(html))
if err != nil {
t.Fatal(err)
}
content = strings.ReplaceAll(content, "\n", "")
content = strings.ReplaceAll(content, " ", "")
content = strings.ReplaceAll(content, "\t", "")
if content != want {
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
}
}
func BenchmarkExtractContent(b *testing.B) {
var testCases = map[string][]byte{
"miniflux_github.html": {},
"miniflux_wikipedia.html": {},
}
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] = data
}
for range b.N {
for _, v := range testCases {
ExtractContent(bytes.NewReader(v))
}
}
}
+1
View File
@@ -0,0 +1 @@
../../reader/sanitizer/testdata/
+22 -18
View File
@@ -7,33 +7,37 @@ package readingtime
import (
"math"
"strings"
"unicode"
"unicode/utf8"
"miniflux.app/v2/internal/reader/sanitizer"
"github.com/abadojack/whatlanggo"
)
// EstimateReadingTime returns the estimated reading time of an article in minute.
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
sanitizedContent := sanitizer.StripTags(content)
truncationPoint := min(len(sanitizedContent), 50)
// Litterature on language detection says that around 100 signes is enough, we're safe here.
truncationPoint := int(math.Min(float64(len(sanitizedContent)), 250))
// We're only interested in identifying Japanse/Chinese/Korean
options := whatlanggo.Options{
Whitelist: map[whatlanggo.Lang]bool{
whatlanggo.Jpn: true,
whatlanggo.Cmn: true,
whatlanggo.Kor: true,
},
}
langInfo := whatlanggo.DetectWithOptions(sanitizedContent[:truncationPoint], options)
if langInfo.IsReliable() {
if isCJK(sanitizedContent[:truncationPoint]) {
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
}
nbOfWords := len(strings.Fields(sanitizedContent))
return int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))
}
func isCJK(text string) bool {
totalCJK := 0
for _, r := range text[:min(len(text), 50)] {
if unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hangul, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Yi, r) ||
unicode.Is(unicode.Bopomofo, r) {
totalCJK++
}
}
// if at least 50% of the text is CJK, odds are that the text is in CJK.
return totalCJK > len(text)/50
}
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
import (
"testing"
)
func TestGetRefererForURL(t *testing.T) {
testCases := []struct {
name string
url string
expected string
}{
{
name: "Weibo Image URL",
url: "https://wx1.sinaimg.cn/large/example.jpg",
expected: "https://weibo.com",
},
{
name: "Pixiv Image URL",
url: "https://i.pximg.net/img-master/example.jpg",
expected: "https://www.pixiv.net",
},
{
name: "SSPai CDN URL",
url: "https://cdnfile.sspai.com/example.png",
expected: "https://sspai.com",
},
{
name: "Instagram CDN URL",
url: "https://scontent-sjc3-1.cdninstagram.com/example.jpg",
expected: "https://www.instagram.com",
},
{
name: "Piokok URL",
url: "https://sp1.piokok.com/example.jpg",
expected: "https://sp1.piokok.com",
},
{
name: "Weibo Video URL",
url: "https://f.video.weibocdn.com/example.mp4",
expected: "https://weibo.com",
},
{
name: "HelloGithub Image URL",
url: "https://img.hellogithub.com/example.png",
expected: "https://hellogithub.com",
},
{
name: "Non-matching URL",
url: "https://example.com/image.jpg",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := GetRefererForURL(tc.url)
if result != tc.expected {
t.Errorf("GetRefererForURL(%s): expected %s, got %s",
tc.url, tc.expected, result)
}
})
}
}

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