Compare commits

...

275 Commits

Author SHA1 Message Date
Frédéric Guillot 875618d786 docs(changelog): update release notes for version 2.2.10 2025-06-23 16:57:29 -07:00
Frédéric Guillot 1503a5c946 docs: add CONTRIBUTING.md file 2025-06-22 12:44:02 -07:00
jvoisin 8641f5f2a3 refactor(database): drop 3 columns in a single transaction 2025-06-20 16:23:20 -07:00
jvoisin 93b17af78b refactor(appjs): no need to check if always present elements are always present 2025-06-20 13:16:57 -07:00
Frédéric Guillot 92876a0c61 refactor(http): rename package from httpd to server for consistency 2025-06-20 13:15:13 -07:00
Frédéric Guillot d62df4e02a refactor(server): avoid double call to Sprintf 2025-06-20 13:05:21 -07:00
Ingmar Stein 8fa5041c37 feat: Allow multiple listen addresses
This change implements the ability to specify multiple listen addresses.
This allows the application to listen on different interfaces or ports simultaneously,
or a combination of IP addresses and Unix sockets.

Closes #3343
2025-06-20 12:46:34 -07:00
Julien Voisin dc05965895 chore(template): remove X-UA-Compatible meta tag specific to Internet Explorer 2025-06-20 11:59:08 -07:00
jvoisin 109e668ac7 perf(storage): pre-allocate a slice in RefreshFeedEntries 2025-06-20 11:57:04 -07:00
Frédéric Guillot 6d58052504 fix(readability): do not remove elements within code blocks
`<span class="hljs-comment"># exit 1</span>` will match the `unlikelyCandidatesRegexp` because it contains the `comment` string.
2025-06-19 21:03:53 -07:00
Matthaiks 491d51c95f feat(locale): update Polish translation 2025-06-19 18:47:28 -07:00
Frédéric Guillot db49e41acf refactor(processor): move FilterEntryMaxAgeDays filter to filter package 2025-06-19 17:56:45 -07:00
Frédéric Guillot e6b814199b feat(filter): add EntryDate=max-age:duration filter
Example: `EntryDate=max-age:30d` or `EntryDate=max-age:1h`

Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d".
2025-06-19 17:25:19 -07:00
Frédéric Guillot b0a10f02fd feat(css): add margin-bottom to input for consistent spacing 2025-06-19 16:35:17 -07:00
Frédéric Guillot 9c05c3c493 feat(filter): merge user and feed entry filter rules 2025-06-19 16:24:57 -07:00
Frédéric Guillot 2a9d91c783 feat: add entry filters at the feed level 2025-06-19 15:15:16 -07:00
Frédéric Guillot cb59944d6b refactor(processor): move RewriteEntryURL function to rewrite package 2025-06-19 13:22:29 -07:00
Frédéric Guillot c12476c1a9 refactor(filter): avoid code duplication between IsBlockedEntry and IsAllowedEntry functions 2025-06-19 12:55:00 -07:00
Frédéric Guillot bc6ab44ff2 fix(filter): skip invalid rules instead of exiting the loop 2025-06-19 12:36:35 -07:00
Frédéric Guillot 6282ac1f38 refactor(processor): move filters to a filter package 2025-06-19 12:06:30 -07:00
jvoisin 96c0ef4efd refactor(processor): massive refactoring of filters.go
- Use proper variable names for `key=value` strings parts
- Explicitly assign false to the `match` boolean
- Use an explicit `len(parts) == 2` assertion to help the compiler remove
  `isSliceInBounds` calls.
- Refactor identical code into a containsRegexPattern function.
- Early exit when parsing the first date fails when using the `Between`
  operator, instead of trying to parse the second one.
2025-06-19 11:43:47 -07:00
jvoisin b139ac4a2c refactor(youtube): Remove a regex and make use of fetchWatchTime 2025-06-19 11:43:47 -07:00
jvoisin c818d5bbb8 refactor(youtube): initiliaze two maps to the proper length 2025-06-19 11:43:47 -07:00
jvoisin e366710529 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin 5cff4d7117 refactor(processor): remove a duplication function call
As youtubeVideoID is assigned to getVideoIDFromYouTubeURL(entry.URL),
there is no need to call the latter again when we can simly use youtubeVideoID
instead.
2025-06-19 11:43:47 -07:00
jvoisin f31a784eaa refactor(processor): refactor common code into a fetchWatchTime function
Both nebula and odysee were using the same function to parse time.
2025-06-19 11:43:47 -07:00
jvoisin 7edfcc3cf7 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin fe4b00b9f8 refactor(processor): extract some functions into an utils.go file 2025-06-19 11:43:47 -07:00
jvoisin 46b159ac58 refactor(processor): simplify bilibili processing
- Use strings.Contains instead of a regex
- Use strings concatenation instead of a call to fmt.Sprintf
- Use `any` instead of `interface{}`
2025-06-19 11:43:47 -07:00
jvoisin 86c58e11f6 perf(reader): use a non-cryptographic hash when possible
There is no need to use SHA256 everywhere, especially on small inputs where we
don't care about its cryptographic properties. We're using FNV as it's the
faster available hash in go's standard library, and we're picking its "a"
version as it's slightly better avalanche characteristics, which are
relevant for small inputs.

This commit has the side-effect of invalidating all favicons saved in the
database, which is desirable to benefit from the resize process implemented in
777d0dd2, as it didn't apply retro-actively.

We're also making use of hex.EncodeToString instead of fmt.Sprintf, as it's
marginally faster.

Note that we can't change the usage of sha256 for feed.Hash as it's used to
deduplicate entries in the database.
2025-06-18 20:28:23 -07:00
jvoisin 9a1d9593b3 refactor(crypto): use rand.Text() instead of a custom implementation
Go 1.24 provides the helpful rand.Text() function, returning a base32-encoded
string containing at least 128 bits of randomness. We should make use of it
everywhere it makes sense to do so, if only to not having to think about much
entropy do we need for each cases, and just trust the go crypto team.

Also, rand.Read() can't fail, so no need to check its return value:
https://pkg.go.dev/crypto/rand#Read This behaviour is consistent with go's
standard library itself.
2025-06-18 20:12:55 -07:00
jvoisin 43546976d2 refactor(tests): use b.Loop() instead of for range b.N
See https://tip.golang.org/doc/go1.24#new-benchmark-function
2025-06-18 20:12:55 -07:00
jvoisin 3ab9ca9e4d refactor(http): Don't hardcode TLS configuration
- TLS 1.2 is used as MinVersion by default
- With regard to CipherSuites, in Go 1.22 RSA key exchange based cipher suites
  were removed from the default list, and in Go 1.23 3DES cipher suites were
  removed as well. Ciphers for TLS1.3 aren't configurable.
- No need to specify CurveP25, as the servers will likely disable the weird
  ones like CurveP384 and CurveP521. Removing the explicit specification also
  enables the post-quantum X25519MLKEM768, wow!

I trust the go team to make better choices on the long term than us keeping
miniflux up to date with the latest TLS trend.
2025-06-18 20:12:55 -07:00
jvoisin 1af6df7cb9 perf(api): use math/rand/v2 2025-06-18 20:12:55 -07:00
jvoisin 50dff576b0 build(go): bump to go 1.24 2025-06-18 20:12:55 -07:00
Frédéric Guillot 6af4d69c39 test(sanitizer): add test case to cover Vimeo iframe rewrite without query string 2025-06-17 17:55:39 -07:00
Frédéric Guillot 27015a5e34 test(sanitizer): add unit test for 0x0 pixel tracker 2025-06-17 17:42:55 -07:00
jvoisin cdb57b3843 perf(sanitizer): minor simplifications of the sanitizer
- Factorize some conditions
- Remove useless `default` case and move the return at the end of the functions
- Use strings.CutPrefix instead of strings.HasPrefix + strings.TrimPrefix
- Use switch-case constructs instead of slices.Contains, as this reduces the
  complexity of the functions and allows them to be inlined, as well as helping
  the compiler to optimize them, as it sucks at interprocedural optimizations.
2025-06-17 17:42:45 -07:00
jvoisin 152ef578d2 feat(sanitizer): consider images of size 0x0 as pixel trackers 2025-06-17 17:32:00 -07:00
jvoisin 72486b9bd1 refactor(processor): minor simplification of a loop
This makes the code a tad clearer.
2025-06-17 17:30:13 -07:00
jvoisin 81df0b2a16 perf(rewrite): make getPredefinedRewriteRules O(1) 2025-06-17 17:27:36 -07:00
jvoisin b296f21e98 refactor(internal): add an urllib.DomainWithoutWWW function 2025-06-17 17:27:36 -07:00
jvoisin af15032145 perf(fetcher): pre-allocate the cipherSuites 2025-06-17 16:53:00 -07:00
jvoisin 6a6a88d06d docs(readme): document a couple of nifty features 2025-06-17 16:50:38 -07:00
jvoisin 8660f5e3c7 perf(media): minor regex simplification
The previous regex was using the [ABC..D]*[ABC] pattern, resulting in a lot of
backtracking. The new regex is stopping the matching at the first space or end
of text (and removes the trailing `.` should one be present).

The backtracking was taking around 50% of the CPU time spent in atom.Parse
2025-06-17 16:49:07 -07:00
Frédéric Guillot da4ab4263c feat(rewrite): add parkablogs.com to the referer override list 2025-06-16 20:28:11 -07:00
jvoisin 237672a62c perf(sanitizer): use a switch-case instead of a map
This removes a heap allocation, and should be way faster. It also makes the
code shorted/simpler.
2025-06-16 14:54:48 -07:00
jvoisin e9d4a130fd refactor(sanitizer): remove two useless www. prefixes
No need to have those prefixes, as the check is for substrings, so removing
them will improve the amount of matches.
2025-06-16 14:53:15 -07:00
Frédéric Guillot d291d6a74d refactor(config): remove deprecated config options 2025-06-15 14:42:28 -07:00
Frédéric Guillot e0f7e6f2a8 feat(config)!: remove SERVER_TIMING_HEADER config option
BREAKING CHANGE: This option is not really useful and it's used only on
the unread page.
2025-06-15 14:17:28 -07:00
jvoisin ef3dbd3707 perf(database): use TRUNCATE instead of DELETE FROM in migrations
This is marginally faster.
2025-06-15 13:58:46 -07:00
jvoisin 32f08053aa perf(database): marginally speeds migrations up
PostgreSQL allows table alterations to be done in a single query, so let's take
advantage of it. This should marginally speed the CI up.
2025-06-15 13:58:46 -07:00
jvoisin 117c031f1c feat(integration)!: remove Pocket integration
BREAKING CHANGE: Pocket will no longer be available after July 8, 2025.

https://support.mozilla.org/en-US/kb/future-of-pocket#w_when-is-pocket-shutting-down
2025-06-15 13:29:55 -07:00
Frédéric Guillot b95c9023ee refactor(sanitizer): make isValidAttribute() check O(1) 2025-06-13 21:44:25 -07:00
Frédéric Guillot 3538c4271b refactor(sanitizer): use global variables to avoid recreating slices on every call 2025-06-13 21:34:07 -07:00
Frédéric Guillot ac44507af2 refactor(sanitizer): use a map for iframe allow list 2025-06-13 21:05:23 -07:00
jvoisin 44c48d109f perf(sanitizer): extract a call to url.Parse and make intensive use of it
Previously, url.Parse(baseUrl) was called on every self-closing tags, and on
most opening tags, accounting for around 15% of the CPU time spent in
processor.ProcessFeedEntries
2025-06-13 17:05:17 -07:00
Frédéric Guillot 40727704c2 feat(rewrite): add support for YouTube Shorts video URL pattern 2025-06-12 21:02:46 -07:00
jvoisin 8a014c6abc perf(readability): minor regex improvement
- Improve the check for tags by matching only if its name is followed either by
  a space, a slash or a closing angle
- Use an anonymous group
2025-06-12 19:13:58 -07:00
jvoisin 60ad19c427 perf(rss): early return when looking for an item's author
The `sanitizer.StripTags` function is calling `html.NewTokenizer`, which is
allocating a 4096 bytes buffer on the heap, as well a running a complex state
machine to tokenize html. There is no need to do all of this for empty strings.

This commit also fixes a TrimSpace/StripTags call inversion.
2025-06-11 19:06:15 -07:00
jvoisin f40c1e7f63 fix(reader): fix a crash introduced by d59990f1
And add a fuzzer and a testcase as well to validate that nothing breaks.
2025-06-11 19:04:46 -07:00
Frédéric Guillot a4d16cc5c1 refactor(rewrite): rename Rewriter function to ApplyContentRewriteRules 2025-06-10 20:28:15 -07:00
jvoisin 7c857bdc72 perf(reader): optimize RemoveTrackingParameters
A bit more than 10% of processor.ProcessFeedEntries' CPU time is spent in
urlcleaner.RemoveTrackingParameters, specifically calling url.Parse, so let's
extract this operation outside of it, and do it once before calling
urlcleaner.RemoveTrackingParameters multiple times.

Co-authored-by: Frédéric Guillot <f@miniflux.net>
2025-06-10 19:29:25 -07:00
jvoisin 0caadf82f2 perf(rss): optimize a bit BuildFeed
Calls to urllib.AbsoluteURL take a bit less than 10% of the time spent in
parser.ParseFeed, completely parsing an url only to check if it's absolute, and
if not, to make it so.

Checking if it starts with `https://` or `http://` is usually enough to find if
an url is absolute, and if is doesn't, it's always possible to fall back to
urllib.AbsoluteURL.

This also comes with the advantage of reducing heap allocations, as most of the
time spent in urllib.AbsoluteURL is heap-related (de)allocations.
2025-06-10 19:23:16 -07:00
jvoisin 0086e0b356 perf(validator): slightly optimize a regex
- There is no need to have groups as we're only using this regex for
  `MatchString`.
- Since the only place where this regex is used is already calling
  strings.ToLower, there is no need to check for `A-Z`.
2025-06-10 19:20:58 -07:00
Frédéric Guillot 70b513b8db feat(ui): display external URL in single entry view
Display the article's external URL directly in the single entry view.

Rationale: On mobile devices, users couldn't see where a link pointed before tapping it.
Previously, the only way to view the external URL was by hovering - an action not available on touch devices.
2025-06-09 21:14:55 -07:00
Frédéric Guillot cecc18420d feat(sanitizer): add validation for empty width and height attributes in img tags 2025-06-09 20:38:17 -07:00
Frédéric Guillot d53fd17e10 feat(sanitizer): validate MathML XML namespace 2025-06-09 20:28:54 -07:00
Frédéric Guillot 21d22d7f0b feat(sanitizer): add support for fetchpriority and decoding attributes in img tags 2025-06-09 20:12:15 -07:00
jvoisin d59990f1dd perf(xml): optimize xml filtering
Instead of using bytes.Map which is returning a copy of the provided []byte,
use a custom in-place implementation, as the bytes.Map call is taking around
25% of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 49085daefe perf(xml): optimized NewXMLDecoder
io.ReadAll is growing the underlying buffer progressively, while
io.Copy is able to allocate it in one go, which is significantly faster.
io.ReadAll is currently accounting for around 10% of the CPU time of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 5872710d22 perf(storage): optimize away two Sprintf calls
The call to fmt.Sprintf in WithFeedID accounts for more than 20% of the time
spent in GetFeed. Use strconv.Itoa instead, as it's much much faster.
Also change WithCategoryID in the same way, for consistency's sake.
2025-06-09 13:10:51 -07:00
Qeynos d2212dee12 feat(locale): update Chinese translations 2025-06-08 21:11:04 -07:00
Frédéric Guillot 8db637cb39 feat(ui): add user setting to control target="_blank" on links
Rationale: Opening links in the current tab is the default browser behavior.

Using `target="_blank"` on external links can lead to accessibility issues and override user preferences. It may also interfere with assistive technologies and expected browser behavior.

To maintain backward compatibility, this option is enabled by default (`true`), which adds `target="_blank"` to links.
2025-06-08 21:07:11 -07:00
Frédéric Guillot 699deea72c feat(oidc): use preferred_username first instead of email claim 2025-06-08 18:05:47 -07:00
Frédéric Guillot c41d189a7a fix(karakeep): correct method name and improve error handling in SaveURL 2025-06-08 17:47:20 -07:00
Frédéric Guillot adfc38d237 feat(locale): update locales using machine translation 2025-06-08 17:14:45 -07:00
Frédéric Guillot a8bb7a48d7 feat(ui): avoid showing an excessive number of tags 2025-06-08 15:29:09 -07:00
jvoisin f9dce3d10f perf(timzone): cache getLocation's results
Every time getLocation is called, it's opening and parsing a file on disc,
sometimes a zip file depending on the system. We can cache the results instead
of doing this.

See https://github.com/golang/go/issues/24844 and https://github.com/golang/go/issues/26106
2025-06-08 13:50:18 -07:00
dependabot[bot] 567e8cfc89 build(deps): bump golang.org/x/image from 0.27.0 to 0.28.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/image/compare/v0.27.0...v0.28.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:09:51 -07:00
dependabot[bot] a8e73d6875 build(deps): bump golang.org/x/net from 0.40.0 to 0.41.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:04:04 -07:00
dependabot[bot] fa7f2b18a0 build(deps): bump golang.org/x/crypto from 0.38.0 to 0.39.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 17:48:47 -07:00
Jesse Jaggars 43d302e768 feat: adding support for saving entries to karakeep
Signed-off-by: Jesse Jaggars <jhjaggars@gmail.com>
2025-06-04 21:10:26 -07:00
dependabot[bot] 4e181330d0 build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.21 to 3.22.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 19:17:39 -07:00
the7thNightmare 0369f03940 feat(locale): update Indonesian translations 2025-05-28 20:45:45 -07:00
Qeynos 4597d9b289 feat(locale): update Chinese translations 2025-05-28 20:44:40 -07:00
Cthulhux 7bfd22aab7 feat(locale): update German translation
Translated one string, found a good wording for the other.
2025-05-27 19:17:23 -07:00
Frédéric Guillot 325c505b88 docs(changelog): update release notes for version 2.2.9 2025-05-26 18:13:05 -07:00
Frédéric Guillot bfd8860398 feat(api): add new endpoints to manage API keys 2025-05-25 15:50:13 -07:00
Matthaiks ebd65da3b6 feat(locale): update Polish translation 2025-05-25 15:30:36 -07:00
Frédéric Guillot 83191b0c1d fix(storage): remove extra comma introduced by commit 09fb05a 2025-05-25 13:33:41 -07:00
Frédéric Guillot 8142268799 feat: populate feed description automatically 2025-05-24 21:15:52 -07:00
Frédéric Guillot 5920e02562 feat: add liveness and readiness probes
- Added new routes: /liveness, /healthz, /readiness, /readyz
- These routes do not take the base path into consideration and are always available at the root of the server
2025-05-24 20:36:05 -07:00
Kelly Norton 09fb05aaaf feat: add option to always open articles externally 2025-05-24 19:46:01 -07:00
Frédéric Guillot 52b184394f fix(migrations): prevent failure at v45 with long entry URLs
Fixes an issue where upgrading from older versions of Miniflux could fail with the following PostgreSQL error:

```
[FATAL] [Migration v45] pq: index row size 2744 exceeds btree version 4 maximum 2704 for index "entries_feed_url_idx"
```
2025-05-23 13:27:05 -07:00
Matthaiks 7c8c7c2711 feat(locale): update Polish translation 2025-05-23 12:21:28 -07:00
Frédéric Guillot 9768eb9fb9 feat(locale): update French translations 2025-05-22 20:28:38 -07:00
Tianzhi Jin b65373db7e feat(webauthn): perfer creation of a client-side discoverable credential 2025-05-22 20:14:00 -07:00
dependabot[bot] 596d22c02c build(deps): bump github.com/tdewolff/minify/v2 from 2.23.6 to 2.23.8
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.6 to 2.23.8.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.6...v2.23.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 19:32:06 -07:00
Anton Larionov 4b86570b7c chore(gitignore): ignore miniflux binary in root directory 2025-05-22 19:31:52 -07:00
Anton Larionov e99864a456 fix(locale): localize Git commit label at about page 2025-05-22 19:30:10 -07:00
Anton Larionov 225463817c feat(locale): complete Russian translation 2025-05-20 19:37:41 -07:00
Matthaiks 3db6e822cb feat(locale): update Polish translation 2025-05-20 19:36:44 -07:00
dependabot[bot] 1c19151925 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.5 to 2.23.6
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.5 to 2.23.6.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.5...v2.23.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-20 19:36:20 -07:00
Anton Larionov 553c578f2e feat(rssbridge): support auth token for RSS-Bridge 2025-05-19 20:47:12 -07:00
Tianzhi Jin 81ec32a8b6 fix(webauthn): correct arg in debug log 2025-05-14 21:01:52 -07:00
dependabot[bot] 3818a8a4fb build(deps): bump github.com/go-webauthn/webauthn from 0.12.3 to 0.13.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.3 to 0.13.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-13 19:17:05 -07:00
Frédéric Guillot 036704b3e4 feat(response): change error response content type to plain text and escape HTML
Adding another layer of security in addition to the existing CSP cannot
hurt.
2025-05-11 19:15:54 -07:00
Frédéric Guillot 327d027d38 feat(settings): replace div.panel with paragraph tags for OAuth2 links 2025-05-11 18:06:16 -07:00
Frédéric Guillot 5ae2cbd943 feat(settings): add validation for entry order and categories sorting order 2025-05-11 17:52:59 -07:00
dependabot[bot] f15d29deb3 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.3 to 2.23.5
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.3 to 2.23.5.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.3...v2.23.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-08 19:03:12 -07:00
Frédéric Guillot 828a4334db fix(sanitizer): MathML tags are not fully supported by golang.org/x/net/html
See https://github.com/golang/net/blob/master/html/atom/gen.go
and https://github.com/golang/net/blob/master/html/atom/table.go
2025-05-06 21:18:19 -07:00
jvoisin d1dc369bb2 feat(sanitizer): add MathML tags to the sanitizer
This was found by reading the article pointed by https://lobste.rs/s/nobvmp/how_prime_factorizations_govern_collatz
2025-05-06 20:19:56 -07:00
Frédéric Guillot a8076e1891 ci: remove deprecated reviewers field from dependantbot.yml 2025-05-06 20:17:19 -07:00
dependabot[bot] 3448d6267c build(deps): bump golang.org/x/net from 0.39.0 to 0.40.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:10:57 -07:00
dependabot[bot] 159261f2f8 build(deps): bump golang.org/x/oauth2 from 0.29.0 to 0.30.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:07:15 -07:00
jvoisin ff2dfe977b feat: remove the ref parameter from url
This is used by (at least) Ghost (https://forum.ghost.org/t/ref-parameter-being-added-to-links/38335)

Examples:
- https://blog.exploits.club/exploits-club-weekly-newsletter-66-mitigations-galore-dirtycow-revisited-program-analysis-for-uafs-and-more/
- https://labs.watchtowr.com/is-the-sofistication-in-the-room-with-us-x-forwarded-for-and-ivanti-connect-secure-cve-2025-22457/
2025-05-06 19:59:55 -07:00
dependabot[bot] a5e3719773 build(deps): bump golang.org/x/crypto from 0.37.0 to 0.38.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:32:46 -07:00
dependabot[bot] cdadb87203 build(deps): bump golang.org/x/image from 0.26.0 to 0.27.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/image/compare/v0.26.0...v0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:28:02 -07:00
dependabot[bot] 5284d61fe3 build(deps): bump golangci/golangci-lint-action from 7 to 8
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:25:14 -07:00
Frédéric Guillot 3de9629a49 feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler 2025-05-04 20:38:53 -07:00
Frédéric Guillot 8d821dfc3b fix(googlereader): handle various item ID formats
- Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
- NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
- Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
- Liferea uses this format: "12345" (decimal string)
2025-05-04 20:11:37 -07:00
Frédéric Guillot cb775bc79e refactor(googlereader): move constants to separate files 2025-05-04 13:02:54 -07:00
Frédéric Guillot 6cc8d8abf1 fix(googlereader): /items/contents should accept short form item IDs 2025-05-03 21:48:41 -07:00
Frédéric Guillot 50395f13ca feat(googlereader): add mark-all-as-read endpoint 2025-05-03 18:38:54 -07:00
Frédéric Guillot e8c3435bb9 fix(googlereader): return a 400 instead of 500 for invalid edit requests 2025-05-02 18:15:00 -07:00
Frédéric Guillot 9a8a8bdca3 refactor(googlreader): remove redundant log message 2025-05-02 17:56:21 -07:00
Frédéric Guillot 63f0a17388 fix(googlereader): avoid panic for inexisting feed or category 2025-05-02 17:42:25 -07:00
NoelNegash 81c7669945 feat(sanitized): allow Spotify iframes 2025-05-02 16:25:17 -07:00
dependabot[bot] 2b000d1022 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.2 to 2.23.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.2 to 2.23.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.2...v2.23.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-02 16:20:29 -07:00
dependabot[bot] 27253c8a97 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.1 to 2.23.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.1 to 2.23.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.1...v2.23.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 16:54:23 -07:00
Frédéric Guillot f68046cce0 docs(changelog): update release notes for version 2.2.8 2025-04-22 21:01:43 -07:00
Frédéric Guillot d33e305af9 fix(api): hide_globally categories field should be a boolean 2025-04-21 19:43:25 -07:00
Frédéric Guillot 764212f37c refactor(js): replace DomHelper methods with standalone functions 2025-04-17 18:15:08 -07:00
Tali Auster e02b65d4bc fix: deal with navigator.share exceptions
Navigator.share returns a promise that's executed in the background, but
unless we await it explicitly, we won't get the exceptions in the
try/catch block.
2025-04-17 17:07:38 -07:00
Tali Auster fe7ec25a09 chore: fix indentation 2025-04-17 17:07:38 -07:00
Tali Auster 2959a4d2bf fix: clarify share flow in UI
Prior to this commit, to share an entry, a user has to click on the
share link and then copy the URL they are redirected to. The danger is
that they may right-click and copy the share link without actually
clicking on it, and therefore share a link that, when authenticated,
shares the entry, rather than actually sharing the entry.

Here, we avoid this misinterpretation by making sharing into a POST
request and using a form rather than a link.
2025-04-17 17:07:38 -07:00
AiraNadih 6b70a7dc81 feat(api): add update_content query parameter to /entries/{entryID}/fetch-content endpoint 2025-04-17 12:41:58 -07:00
dependabot[bot] 495c6aacd9 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.27 to 1.14.28
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.27 to 1.14.28.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.27...v1.14.28)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-16 19:27:11 -07:00
Frédéric Guillot bc228d0afe ci: add documentation issue template 2025-04-12 14:29:52 -07:00
Frédéric Guillot d139d8a6ce feat(cli): add -reset-feed-next-check-at argument 2025-04-11 15:56:57 -07:00
dependabot[bot] 28d0185e79 build(deps): bump github.com/PuerkitoBio/goquery from 1.10.2 to 1.10.3
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.2 to 1.10.3.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.2...v1.10.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-11 15:44:47 -07:00
Frédéric Guillot c87c93d85f feat(config): add SCHEDULER_ROUND_ROBIN_MAX_INTERVAL option
Add option to cap maximum refresh interval when RSS TTL, Retry-After, Cache-Control, or Expires headers specify excessively high values.
2025-04-11 15:40:32 -07:00
dependabot[bot] 0ef21e85c2 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.1 to 1.22.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.21.1...v1.22.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 20:53:15 -07:00
dependabot[bot] 7438d67061 build(deps): bump github.com/tdewolff/minify/v2 from 2.22.4 to 2.23.1
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.22.4 to 2.23.1.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.22.4...v2.23.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:49:12 -07:00
dependabot[bot] e4399b4f9a build(deps): bump golang.org/x/image from 0.25.0 to 0.26.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/image/compare/v0.25.0...v0.26.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:48:54 -07:00
dependabot[bot] b4f891705a build(deps): bump golang.org/x/oauth2 from 0.28.0 to 0.29.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.28.0...v0.29.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:10:31 -07:00
dependabot[bot] 3d72404ace build(deps): bump golang.org/x/net from 0.38.0 to 0.39.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 19:55:08 -07:00
Matthaiks c4e92e6111 feat(locale): update Polish translation 2025-04-07 12:06:44 -07:00
Frédéric Guillot ef22e95f8b feat: implement proxy URL per feed 2025-04-06 21:05:19 -07:00
tssujt 7b344de846 feat(telegrambot): replace "Go to website" button with "Go to Miniflux" 2025-04-06 18:30:42 -07:00
Frédéric Guillot c45b51d1f8 feat: use Cache-Control max-age and Expires headers to calculate next check 2025-04-06 16:24:00 -07:00
Frédéric Guillot 0af1a6e121 refactor: avoid logging twice the feed errors in the background worker 2025-04-06 15:39:40 -07:00
Frédéric Guillot 535fd050b7 feat: add proxy rotation functionality 2025-04-06 14:59:00 -07:00
Frédéric Guillot d20e8a4e2c ci(linter): replace commitlint with a Python script 2025-04-05 20:41:34 -07:00
Qeynos 7514e8a0c1 feat(locale): update Chinese translation 2025-04-04 19:40:44 -07:00
Cthulhux ca3ede3183 Update de_DE.json
More translations
2025-04-04 19:37:53 -07:00
dependabot[bot] 038d33600a build(deps): bump github.com/coreos/go-oidc/v3 from 3.13.0 to 3.14.1
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.13.0 to 3.14.1.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.13.0...v3.14.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-03 17:34:17 -07:00
dependabot[bot] 1a3a174688 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.25 to 1.14.27
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.25 to 1.14.27.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.25...v1.14.27)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-02 19:25:34 -07:00
dependabot[bot] 6848c759b5 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.24 to 1.14.25
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.24 to 1.14.25.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.24...v1.14.25)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 19:46:53 -07:00
dependabot[bot] 10578f512f build(deps): bump github.com/go-webauthn/webauthn from 0.12.2 to 0.12.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.2 to 0.12.3.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.2...v0.12.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 19:46:40 -07:00
Frédéric Guillot f99dff5238 docs(changelog): update release notes for version 2.2.7 2025-04-01 17:18:32 -07:00
Frédéric Guillot 1936ced1ed docs: update README 2025-03-30 11:18:16 -07:00
Frédéric Guillot cb695e653a fix(security): use a more restrictive CSP for untrusted content 2025-03-29 19:49:41 -07:00
Matthaiks f57949c9a2 feat(locale): update Polish translation 2025-03-29 15:15:21 -07:00
Frédéric Guillot 94fb3d6159 fix: change labels from Read / Unread to "Mark as Read" 2025-03-28 17:34:44 -07:00
Frédéric Guillot 51560f191f fix(subscription): add /rss/feed.xml to the list of known feed URLs 2025-03-28 16:59:06 -07:00
Frédéric Guillot c0fcdf3b6e fix(ui): update entry tags display logic to show links based on user authentication 2025-03-28 15:53:08 -07:00
Frédéric Guillot 969efd2af7 fix(ui): update share feature to correctly select title element and handle empty title 2025-03-28 15:36:43 -07:00
Frédéric Guillot 87fccfee3a fix(ui): remove touch-action style because it could cause horizontal scrolling issue 2025-03-28 15:20:31 -07:00
Frédéric Guillot e643effefa feat: combine feed icon handlers to use only externalIconID 2025-03-28 14:29:19 -07:00
Frédéric Guillot c531be8780 fix: update Content-Security-Policy to use 'sandbox' directive 2025-03-28 13:06:59 -07:00
Frédéric Guillot 6e3cecc57e feat(locale): add Romanian translation
This contribution was sent outside GitHub. Credit to Laurentiu.
2025-03-28 11:11:20 -07:00
Tali Auster b3d385861f fix: log warning on an empty client secret
Because Miniflux runs as a confidential service, a missing client secret
is a mistake in configuration. An empty client secret appears to be
valid per RFC 6749 (and is in fact the default set by Miniflux!), so we
log a warning.
2025-03-27 20:00:09 -07:00
dependabot[bot] cc5a8fa4b3 build(deps): bump golang.org/x/net from 0.37.0 to 0.38.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.38.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-03-27 17:26:54 -07:00
Frédéric Guillot 78ff727064 fix: name "external_id": converting NULL to string is unsupported
Regresion introduced by commit df8bc742fb.
2025-03-26 20:51:57 -07:00
dependabot[bot] 54dad707a4 build(deps): bump github.com/tdewolff/minify/v2 from 2.22.3 to 2.22.4
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.22.3 to 2.22.4.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.22.3...v2.22.4)

---
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-03-26 19:48:23 -07:00
Josiah Campbell df8bc742fb feat(googlereader): add feed icon field, endpoint
Adds an endpoint to the Google Reader integration to serve
feed icon URLs.
2025-03-25 20:55:16 -07:00
Frédéric Guillot e342a4f143 fix: address minor issues detected by Go linters 2025-03-24 20:48:46 -07:00
dependabot[bot] febb7b1748 build(deps): bump golangci/golangci-lint-action from 6 to 7
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6 to 7.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-24 20:02:03 -07:00
Frédéric Guillot 537867a64b ci: replace GitHub Issue Markdown templates with YAML forms 2025-03-24 17:27:31 -07:00
Frédéric Guillot 82a6fe64ae feat(storage): reduce the number of SQL queries when fetching entry enclosures 2025-03-23 14:13:33 -07:00
dependabot[bot] 984a66b590 build(deps): bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 15:33:19 -07:00
dependabot[bot] bd79ad2a7d build(deps): bump github.com/tdewolff/minify/v2 from 2.22.2 to 2.22.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.22.2 to 2.22.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.22.2...v2.22.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-03-20 20:26:38 -07:00
dependabot[bot] b583de88f3 build(deps): bump github.com/tdewolff/minify/v2 from 2.21.3 to 2.22.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.21.3 to 2.22.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.21.3...v2.22.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-14 17:57:04 -07:00
dependabot[bot] baccb9e719 build(deps): bump github.com/coreos/go-oidc/v3 from 3.12.0 to 3.13.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.12.0 to 3.13.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.12.0...v3.13.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-03-13 19:30:26 -07:00
dependabot[bot] 0fd11693f3 build(deps): bump github.com/go-webauthn/webauthn from 0.12.1 to 0.12.2 (#3209)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.1 to 0.12.2.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.1...v0.12.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 18:02:27 -07:00
Matthaiks 05f7a34d43 feat(locale): update Polish translation 2025-03-07 11:36:25 -08:00
Frédéric Guillot 315e72c412 fix(rewrite): remove obsolete rule for webtoons.com 2025-03-06 20:11:03 -08:00
jvoisin f916373f55 fix: allow the <b> tag 2025-03-06 19:27:30 -08:00
jvoisin 5353211206 fix: allow the <u> tag in feeds 2025-03-06 19:26:26 -08:00
dependabot[bot] 999ab0df2b build(deps): bump golang.org/x/net from 0.36.0 to 0.37.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.36.0 to 0.37.0.
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.37.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-03-05 16:29:56 -08:00
dependabot[bot] f8700c84b8 build(deps): bump golang.org/x/image from 0.24.0 to 0.25.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/image/compare/v0.24.0...v0.25.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-03-05 16:22:29 -08:00
dependabot[bot] ddd916161e build(deps): bump golang.org/x/crypto from 0.35.0 to 0.36.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/crypto/compare/v0.35.0...v0.36.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-03-05 16:16:27 -08:00
dependabot[bot] c3cd5e3109 build(deps): bump golang.org/x/oauth2 from 0.27.0 to 0.28.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.27.0...v0.28.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-03-05 16:11:59 -08:00
dependabot[bot] 051441d875 build(deps): bump golang.org/x/term from 0.29.0 to 0.30.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/term/compare/v0.29.0...v0.30.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-03-05 15:53:41 -08:00
Paul Stenius 0cf5051cf8 feat: show size of DB on the about page 2025-03-05 15:49:32 -08:00
Paul Stenius 12ca7aa085 feat: add a make add string command to add new localized strings 2025-03-04 21:22:33 -08:00
dependabot[bot] d53dc4cf28 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.0 to 1.21.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.21.0...v1.21.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-04 16:47:52 -08:00
dependabot[bot] e7298b255a build(deps): bump golang.org/x/net from 0.35.0 to 0.36.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.36.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-03-04 16:45:39 -08:00
AiraNadih ad02f21d04 refactor(rewrite): reorganize referer rules and remove obsolete mapping 2025-03-02 19:40:52 -08:00
Frédéric Guillot 46323b3fe7 test(api): update base url after upgrading hugo 2025-03-02 19:32:09 -08:00
David Pedersen 86b3593fec docs: update client README to not reference deprecated function 2025-02-27 17:52:08 -08:00
Frédéric Guillot d7c504f48e fix(ui): avoid 500 error and NaN when marking as read a deleted entry
Steps to reproduce:

1. In /unread, open a feed's settings in a new tab. The feed must have unread entries in /unread.
2. In the new tab/window, delete the feed.
3. Without refreshing, mark an entry from the now-deleted feed as read.
4. Result: The total unread count in the UI header switches to NaN.
2025-02-26 16:39:38 -08:00
Matthaiks 6ad2001c7d feat(locale): update Polish translation 2025-02-26 16:33:18 -08:00
dependabot[bot] c5d4c762d8 build(deps): bump golang.org/x/crypto from 0.33.0 to 0.35.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.33.0 to 0.35.0.
- [Commits](https://github.com/golang/crypto/compare/v0.33.0...v0.35.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-24 16:59:46 -08:00
dependabot[bot] 8b66c2be1d build(deps): bump golang.org/x/oauth2 from 0.26.0 to 0.27.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.26.0...v0.27.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-24 16:58:57 -08:00
dependabot[bot] cfb22d7ec1 build(deps): bump github.com/go-webauthn/webauthn from 0.11.2 to 0.12.1
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.11.2 to 0.12.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.11.2...v0.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 16:54:07 -08:00
Maytham Alsudany f01ff067a5 fix(processor): add missing quotation marks to import comments 2025-02-24 16:34:26 -08:00
Uziskull 55a3f9fcc9 feat(integrations/ntfy): make ntfy topics configurable per feed, with default one as fallback 2025-02-24 16:29:08 -08:00
dependabot[bot] 996f6f68d2 build(deps): bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.2 to 4.0.5.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.2...v4.0.5)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 16:03:48 -08:00
Matthaiks 3b8ac8b16a feat(locale): update Polish translation 2025-02-23 11:46:28 -08:00
Frédéric Guillot 381f03b56a fix(greader): return enclosures in streamItemContentsHandler response 2025-02-22 20:11:41 -08:00
jvoisin 117d711d7d feat(urlcleaner): add more Google Analytics parameters 2025-02-22 17:07:59 -08:00
dependabot[bot] 2f71804ff4 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.5 to 1.21.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.5...v1.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-22 17:06:47 -08:00
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
279 changed files with 20391 additions and 13428 deletions
-10
View File
@@ -1,10 +0,0 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: bug, triage needed
assignees: ''
---
+105
View File
@@ -0,0 +1,105 @@
name: "Bug Report"
description: "Report a bug or unexpected behavior"
title: "[Bug]: "
type: "Bug"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.
- type: input
id: summary
attributes:
label: "Bug Summary"
description: "Briefly describe the bug."
placeholder: "e.g., Error when saving a new entry"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "A clear and concise description of the bug."
placeholder: "e.g., When I click 'Save', I get a 500 error."
validations:
required: true
- type: textarea
id: steps_to_reproduce
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior."
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "What should happen instead?"
placeholder: "e.g., The form should be saved successfully."
validations:
required: true
- type: textarea
id: actual_behavior
attributes:
label: "Actual Behavior"
description: "What actually happens?"
placeholder: "e.g., A 500 error is returned with no useful error message."
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux are you using?"
placeholder: "e.g., 2.2.6"
validations:
required: true
- type: input
id: browser
attributes:
label: "Browser"
description: "If applicable, which browser are you using? Please provide the version."
placeholder: "e.g., Chrome, Firefox, Safari"
validations:
required: false
- type: textarea
id: logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages (if applicable)."
render: shell
placeholder: "e.g., Stack trace, log files, browser console logs, or console output"
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context about the problem here."
placeholder: "e.g., Screenshots, video recordings, or related issues"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this bug hasn't been reported before."
required: true
+81
View File
@@ -0,0 +1,81 @@
name: "Documentation Issue"
description: "Report issues or suggest improvements for the documentation"
title: "[Docs]: "
type: "Documentation"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.
- type: dropdown
id: issue_type
attributes:
label: "Documentation Issue Type"
description: "What kind of documentation issue are you reporting?"
options:
- "Missing Information"
- "Incorrect Information"
- "Outdated Information"
- "Unclear Explanation"
- "Formatting/Structural Issue"
- "Typo/Grammar Error"
- "Documentation Request"
- "Other"
validations:
required: true
- type: input
id: summary
attributes:
label: "Summary"
description: "Briefly describe the documentation issue."
placeholder: "e.g., The API authentication section is outdated"
validations:
required: true
- type: input
id: location
attributes:
label: "Location"
description: "Where is the documentation you're referring to? Provide URLs, file paths, or section names."
placeholder: "e.g., README.md, docs/api.md, Installation section of the website"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Detailed Description"
description: "Provide a detailed description of the issue or improvement."
placeholder: "e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5."
validations:
required: true
- type: textarea
id: current_content
attributes:
label: "Current Content (if applicable)"
description: "What does the current documentation say?"
placeholder: "Paste the current documentation text here."
validations:
required: false
- type: textarea
id: suggested_content
attributes:
label: "Suggested Changes"
description: "If you have specific suggestions for how to improve the documentation, please provide them here."
placeholder: "e.g., Add a new section about token-based authentication with these details..."
validations:
required: false
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux does this documentation issue relate to?"
placeholder: "e.g., 2.2.6, or 'all versions'"
validations:
required: false
-10
View File
@@ -1,10 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: wishlist
assignees: ''
---
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
@@ -0,0 +1,67 @@
name: "Feature Request"
description: "Suggest an idea or improvement for the project"
title: "[Feature]: "
type: "Feature"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.
- type: input
id: summary
attributes:
label: "Feature Summary"
description: "Briefly describe the feature or enhancement."
placeholder: "e.g., Add dark mode support"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "What problem does this feature solve?"
description: "Explain the problem or limitation this feature would address."
placeholder: "e.g., It's difficult to use the app in low-light environments."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Proposed Solution"
description: "Describe how you think this feature should work."
placeholder: "e.g., Add a toggle in settings to switch between light and dark mode."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "Have you considered other solutions or workarounds?"
placeholder: "e.g., Using browser extensions to force dark mode."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or examples to explain your request."
placeholder: "e.g., A screenshot of a similar feature in another project."
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this feature hasn't been requested before."
required: true
- label: "I understand that feature requests are not guaranteed to be implemented."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
-10
View File
@@ -1,10 +0,0 @@
---
name: Feed Problems
about: Problems with a feed or a website
title: ''
labels: feed problems, triage needed
assignees: ''
---
+84
View File
@@ -0,0 +1,84 @@
name: "Feed/Website Issue"
description: "Report problems with a specific feed or website"
title: "[Feed Issue]: "
type: "Feed Issue"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.
- type: input
id: feed_url
attributes:
label: "Feed URL"
description: "Provide the URL of the feed that is not working correctly."
placeholder: "e.g., https://example.com/feed.xml"
validations:
required: true
- type: input
id: website_url
attributes:
label: "Website URL"
description: "Provide the URL of the website."
placeholder: "e.g., https://example.com"
validations:
required: true
- type: textarea
id: problem_description
attributes:
label: "Problem Description"
description: "Describe the issue you are experiencing with this feed."
placeholder: |
e.g.,
- The feed URL returns a 403 error.
- The content is malformed.
- Images are not loading in the web ui.
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "Describe what you expect to happen."
placeholder: "e.g., The feed should show the images correctly."
validations:
required: true
- type: textarea
id: error_logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages, if available."
render: shell
placeholder: "e.g., HTTP error codes, invalid XML warnings, etc."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or related information to help us troubleshoot."
placeholder: "e.g., Is this a recurring problem? Did the feed work before?"
validations:
required: false
- type: checkboxes
id: troubleshooting
attributes:
label: "Troubleshooting Steps"
description: "Please confirm that you have tried the following:"
options:
- label: "I have checked if the feed URL is correct and accessible in a web browser."
required: true
- label: "I have checked if the feed URL is correct and accessible with `curl`."
required: true
- label: "I have verified that the feed is valid using an RSS/Atom validator."
required: false
- label: "I have searched for existing issues to avoid duplicates."
required: true
+88
View File
@@ -0,0 +1,88 @@
name: "Proposal / RFC"
description: "Propose a significant change, or architectural decision"
title: "[Proposal]: "
type: "Proposal"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.
- type: input
id: summary
attributes:
label: "Proposal Summary"
description: "A brief summary of the proposed change or idea."
placeholder: "e.g., Refactor database schema for performance optimization"
validations:
required: true
- type: textarea
id: motivation
attributes:
label: "Motivation and Context"
description: "Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?"
placeholder: |
e.g.,
- The current database schema causes performance bottlenecks when querying large datasets.
- Adding this feature will improve scalability and reliability for large-scale use cases.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: "Proposed Solution"
description: "Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible."
placeholder: |
e.g.,
- Redesign the schema to normalize tables and introduce indexing.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "List any alternative approaches that were considered and explain why they were rejected."
placeholder: |
e.g.,
- Use Redis for caching, but it adds operational complexity.
- Stick with the current schema and optimize queries, but this has limited impact on performance.
validations:
required: false
- type: textarea
id: impact
attributes:
label: "Impact and Risks"
description: "Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns."
placeholder: |
e.g.,
- May require data migration with downtime.
- Could introduce breaking changes in API responses.
- Affects core functionality, requiring extensive testing.
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: "Additional Context or References"
description: "Add any relevant context, links to related discussions, RFCs, or design documents."
placeholder: "e.g., Links to research, GitHub issues, or similar projects"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have reviewed existing proposals to ensure this change hasn't been proposed before."
required: true
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
-24
View File
@@ -4,52 +4,28 @@ updates:
directory: "/"
schedule:
interval: "daily"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
+4 -4
View File
@@ -1,7 +1,7 @@
Do you follow the guidelines?
Have you followed these guidelines?
- [ ] I have tested my changes
- [ ] There are 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
- [ ] 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 and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
go-version: "1.24.x"
check-latest: true
- name: Compile binaries
env:
+1 -1
View File
@@ -37,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
@@ -6,10 +6,14 @@ on:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
- 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_name == 'schedule'
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
+19 -7
View File
@@ -28,15 +28,27 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- uses: golangci/golangci-lint-action@v6
go-version: "1.24.x"
- uses: golangci/golangci-lint-action@v8
with:
args: >
--timeout 10m
--exclude-dirs=tests
--disable errcheck
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
- uses: dominikh/staticcheck-action@v1.3.1
--enable sqlclosecheck,misspell,whitespace,gocritic
- name: Run gofmt linter
run: gofmt -d -e .
commitlint:
if: github.event_name == 'pull_request'
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
version: "2024.1.1"
install-go: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Validate PR commits
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
+6 -2
View File
@@ -6,10 +6,14 @@ on:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
- 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_name == 'schedule'
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -0,0 +1,93 @@
import subprocess
import re
import sys
import argparse
from typing import Match
# Conventional commit pattern
CONVENTIONAL_COMMIT_PATTERN: str = r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}"
def get_commit_message(commit_hash: str) -> str:
"""Get the commit message for a given commit hash."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "show", "-s", "--format=%B", commit_hash],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error retrieving commit message: {e}")
sys.exit(1)
def check_commit_message(
message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN
) -> bool:
"""Check if commit message follows conventional commit format."""
first_line: str = message.split("\n")[0]
match: Match[str] | None = re.match(pattern, first_line)
return bool(match)
def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
"""Check all commits in a range for compliance."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "log", "--format=%H", f"{base_ref}..{head_ref}"],
capture_output=True,
text=True,
check=True,
)
commit_hashes: list[str] = result.stdout.strip().split("\n")
# Filter out empty lines
commit_hashes = [hash for hash in commit_hashes if hash]
non_compliant: list[dict[str, str]] = []
for commit_hash in commit_hashes:
message: str = get_commit_message(commit_hash)
if not check_commit_message(message):
non_compliant.append(
{"hash": commit_hash, "message": message.split("\n")[0]}
)
return non_compliant
except subprocess.CalledProcessError as e:
print(f"Error checking commit range: {e}")
sys.exit(1)
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Check conventional commit compliance"
)
parser.add_argument(
"--base", required=True, help="Base ref (starting commit, exclusive)"
)
parser.add_argument(
"--head", required=True, help="Head ref (ending commit, inclusive)"
)
args: argparse.Namespace = parser.parse_args()
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
if non_compliant:
print("The following commits do not follow the conventional commit format:")
for commit in non_compliant:
print(f"- {commit['hash'][:8]}: {commit['message']}")
print("\nPlease ensure your commit messages follow the format:")
print("type(scope): subject")
print(
"\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test"
)
sys.exit(1)
else:
print("All commits follow the conventional commit format!")
sys.exit(0)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -15,7 +15,7 @@ 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
@@ -49,7 +49,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
go-version: "1.24.x"
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
+2 -2
View File
@@ -1,7 +1,7 @@
./*.sha256
./miniflux
/miniflux
.idea
.vscode
*.deb
*.rpm
miniflux-*
miniflux-*
+178
View File
@@ -0,0 +1,178 @@
# Contributing to Miniflux
This document outlines how to contribute effectively to Miniflux.
## Philosophy
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
- **Improving existing features takes priority over adding new ones**
- **Quality over quantity** - well-implemented, focused features are preferred
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
## Before You Start
### Feature Requests
Before implementing a new feature:
- Check if it aligns with Miniflux's philosophy
- Consider if the feature could be implemented differently to maintain simplicity
- Remember that developing software takes significant time, and this is a volunteer-driven project
- If you need a specific feature, the best approach is to contribute it yourself
### Bug Reports
When reporting bugs:
- Search existing issues first to avoid duplicates
- Provide clear reproduction steps
- Include relevant system information (OS, browser, Miniflux version)
- Include error messages, screenshots, and logs when applicable
## Development Setup
### Requirements
- **Git**
- **Go >= 1.24**
- **PostgreSQL**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
4. **Run locally in debug mode:**
```bash
make run
```
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
## Development Workflow
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
```
### Building
- **Current platform:** `make miniflux`
- **All platforms:** `make build`
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
- **Docker image:** `make docker-image`
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
## Pull Request Guidelines
### What Is Preferred
✅ **Good Pull Requests:**
- Focus on a single issue or feature
- Include tests for new functionality
- Maintain or improve performance
- Follow existing code style and patterns
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
- Update documentation when necessary
### What to Avoid
❌ **Pull Requests That Cannot Be Accepted:**
- **Too many changes** - makes review difficult
- **Breaking changes** - disrupts existing functionality
- **New bugs or regressions** - reduces software quality
- **Unnecessary dependencies** - conflicts with minimalist approach
- **Performance degradation** - slows down the software
- **Poor-quality code** - hard to maintain
- **Dependent PRs** - creates review complexity
- **Radical UI changes** - disrupts user experience
- **Conflicts with philosophy** - doesn't align with minimalist approach
### Pull Request Template
When creating a pull request, please include:
- **Description:** What does this PR do?
- **Motivation:** Why is this change needed?
- **Testing:** How was this tested?
- **Breaking Changes:** Are there any breaking changes?
- **Related Issues:** Link to any related issues
## Code Style
- Follow Go conventions and best practices
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
- Write clear, descriptive variable and function names
- Include comments for complex logic
- Keep functions small and focused
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves
## Communication
- **Discussions:** Use GitHub Discussions for general questions and community interaction
- **Issues:** Use GitHub issues for bug reports and feature requests
- **Pull Requests:** Use PR comments for code-specific discussions
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
## Questions?
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
- Look at existing issues and pull requests for examples
+261 -1
View File
@@ -1,7 +1,267 @@
Version 2.2.10 (June 23, 2025)
------------------------------
* test(sanitizer): add unit test for 0x0 pixel tracker
* test(sanitizer): add test case to cover Vimeo iframe rewrite without query string
* refactor(youtube): Remove a regex and make use of `fetchWatchTime`
* refactor(youtube): initialize two maps to the proper length
* refactor(tests): use `b.Loop()` instead of for range `b.N`
* refactor(server): avoid double call to `Sprintf`
* refactor(sanitizer): use global variables to avoid recreating slices on every call
* refactor(sanitizer): use a map for iframe allow list
* refactor(sanitizer): remove two useless `www.` prefixes
* refactor(sanitizer): make `isValidAttribute()` check O(1)
* refactor(rewrite): rename `Rewriter` function to `ApplyContentRewriteRules`
* refactor(processor): simplify Bilibili processing
* refactor(processor): remove a useless type declaration
* refactor(processor): remove a duplicated function call
* refactor(processor): refactor common code into a `fetchWatchTime` function
* refactor(processor): move filters to a `filter` package
* refactor(processor): move `FilterEntryMaxAgeDays` filter to filter package
* refactor(processor): move `RewriteEntryURL` function to `rewrite` package
* refactor(processor): minor simplification of a loop
* refactor(internal): add an `urllib.DomainWithoutWWW` function
* refactor(http): rename package from `httpd` to `server` for consistency
* refactor(http): Don't hardcode TLS configuration
* refactor(filter): avoid code duplication between `IsBlockedEntry` and `IsAllowedEntry` functions
* refactor(database): drop 3 columns in a single transaction
* refactor(crypto): use `rand.Text()` instead of a custom implementation
* refactor(config): remove deprecated config options
* refactor(appjs): no need to check if always present elements are always present
* perf(xml): optimized `NewXMLDecoder`
* perf(xml): optimize XML filtering
* perf(validator): slightly optimize a regex
* perf(timezone): cache `getLocation`'s results
* perf(storage): pre-allocate a slice in `RefreshFeedEntries`
* perf(storage): optimize away two `Sprintf` calls
* perf(sanitizer): use a switch-case instead of a map
* perf(sanitizer): minor simplifications of the sanitizer
* perf(sanitizer): extract a call to `url.Parse` and make intensive use of it
* perf(rss): optimize a bit `BuildFeed`
* perf(rss): early return when looking for an item's author
* perf(rewrite): make `getPredefinedRewriteRules` O(1)
* perf(reader): use a non-cryptographic hash when possible
* perf(reader): optimize `RemoveTrackingParameters`
* perf(readability): minor regex improvement
* perf(media): minor regex simplification
* perf(fetcher): pre-allocate the cipherSuites
* perf(database): use `TRUNCATE` instead of `DELETE FROM` in migrations
* perf(database): marginally speeds migrations up
* perf(api): use `math/rand/v2` instead of `math/rand` for better performance
* fix(readability): do not remove elements within code blocks
* fix(karakeep): correct method name and improve error handling in `SaveURL`
* fix(filter): skip invalid rules instead of exiting the loop
* feat(ui): display external link in single entry view because the URL was not visible on mobile (no mouse over)
* feat(ui): avoid showing an excessive number of tags
* feat(ui): add user setting to control `target="_blank"` on links
* feat(sanitizer): validate MathML XML namespace
* feat(sanitizer): consider images of size 0x0 as pixel trackers
* feat(sanitizer): add validation for empty `width` and `height` attributes in img tags
* feat(sanitizer): add support for `fetchpriority` and `decoding` attributes in img tags
* feat(rewrite): add support for YouTube Shorts video URL pattern
* feat(rewrite): add `parkablogs.com` to the referer override list
* feat(oidc): use `preferred_username` first instead of `email` claim
* feat(locale): update Polish translations
* feat(locale): update locales using machine translation
* feat(locale): update Indonesian translations
* feat(locale): update German translations
* feat(locale): update Chinese translations
* feat(integration)!: remove Pocket integration (Pocket will no longer be available after July 8, 2025)
* feat(filter): add `EntryDate=max-age:duration` filter
* feat(css): add margin-bottom to input for consistent spacing
* feat(config)!: remove `SERVER_TIMING_HEADER` config option
* feat: Allow multiple listen addresses
* feat: adding support for saving entries to Karakeep
* feat: add entry filters at the feed level
* docs(readme): document a couple of nifty features
* docs: add `CONTRIBUTING.md` file
* chore(template): remove `X-UA-Compatible` meta tag specific to Internet Explorer
* build(go): bump to go 1.24
* build(deps): bump `library/alpine` in `/packaging/docker/alpine`
* build(deps): bump `golang.org/x/net` from `0.40.0` to `0.41.0`
* build(deps): bump `golang.org/x/image` from `0.27.0` to `0.28.0`
* build(deps): bump `golang.org/x/crypto` from `0.38.0` to `0.39.0`
Version 2.2.9 (May 26, 2025)
----------------------------
* refactor(googlereader): remove redundant log message
* refactor(googlereader): move constants to separate files
* fix(webauthn): correct argument in debug log
* fix(sanitizer): MathML tags are not fully supported by `golang.org/x/net/html`
* fix(migrations): prevent failure at version 45 with long entry URLs
* fix(locale): localize Git commit label in about page
* fix(googlereader): return a 400 instead of 500 for invalid edit requests
* fix(googlereader): handle various item ID formats
* fix(googlereader): avoid panic for inexisting feed or category
* fix(googlereader): `/items/contents` should accept short form item IDs
* feat(webauthn): prefer creation of a client-side discoverable credential
* feat(urlcleaner): remove the `ref` parameter from url
* feat(settings): replace `div.panel` with paragraph tags for OAuth2 links
* feat(settings): add validation for entry order and categories sorting order
* feat(settings): add option to always open articles externally
* feat(server): add liveness and readiness probes
* feat(sanitizer): add MathML tags to the sanitizer
* feat(sanitized): allow Spotify iframes
* feat(rssbridge): support authentication token for RSS-Bridge
* feat(response): change error response content type to plain text and escape HTML
* feat(reader): populate feed description automatically
* feat(locale): update Russian translation
* feat(locale): update Polish translation
* feat(locale): update French translation
* feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler
* feat(googlereader): add `mark-all-as-read` endpoint
* feat(api): add new endpoints to manage API keys
* ci: remove deprecated `reviewers` field from `dependantbot.yml`
* chore(gitignore): ignore miniflux binary in root directory
* build(deps): bump `golangci/golangci-lint-action` from `7` to `8`
* build(deps): bump `golang.org/x/oauth2` from `0.29.0` to `0.30.0`
* build(deps): bump `golang.org/x/net` from `0.39.0` to `0.40.0`
* build(deps): bump `golang.org/x/image` from `0.26.0` to `0.27.0`
* build(deps): bump `golang.org/x/crypto` from `0.37.0` to `0.38.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.3` to `2.23.8`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.1` to `2.23.3`
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.12.3` to `0.13.0`
Version 2.2.8 (April 22, 2025)
------------------------------
* refactor(js): replace `DomHelper` methods with standalone functions
* refactor: avoid logging twice the feed errors in the background worker
* fix(api): `hide_globally` categories field should be a boolean
* fix(ui): add missing `await` when calling `navigator.share()` method
* fix(ui): replace share link with a form button for better accessibility
* feat(telegrambot): replace "Go to website" button with "Go to Miniflux"
* feat(locale): update Polish translation
* feat(locale): update German translation
* feat(locale): update Chinese translation
* feat(config): add `SCHEDULER_ROUND_ROBIN_MAX_INTERVAL` option
* feat(cli): add `-reset-feed-next-check-at` argument
* feat(api): add `update_content` query parameter to `/entries/{entryID}/fetch-content` endpoint
* feat: use `Cache-Control` max-age and `Expires` headers to calculate next check
* feat: implement proxy URL per feed
* feat: add proxy rotation functionality
* ci(linter): replace commitlint with a Python script
* ci: add documentation issue template
* build(deps): bump `golang.org/x/oauth2` from `0.28.0` to `0.29.0`
* build(deps): bump `golang.org/x/net` from `0.38.0` to `0.39.0`
* build(deps): bump `golang.org/x/image` from `0.25.0` to `0.26.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.22.4` to `2.23.1`
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.2` to `1.10.3`
* build(deps): bump `github.com/prometheus/client_golang`
* build(deps): bump `github.com/mattn/go-sqlite3` from `1.14.24` to `1.14.28`
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.12.2` to `0.12.3`
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.13.0` to `3.14.1`
Version 2.2.7 (April 1, 2025)
-----------------------------
* test(api): update base URL after upgrading Hugo
* refactor(rewrite): reorganize referer rules and remove obsolete mappings
* refactor: combine feed icon handlers to use only `externalIconID`
* fix(ui): update share feature to correctly select the title element and handle empty titles
* fix(ui): update entry tags display logic to show links based on user authentication
* fix(ui): remove touch-action style to prevent horizontal scrolling issues
* fix(ui): log a warning for an empty client secret
* fix(ui): change labels from "Read / Unread" to "Mark as Read"
* fix(ui): avoid 500 errors and NaN when marking a deleted entry as read
* fix(subscription): add `/rss/feed.xml` to the list of known feed URLs
* fix(security): use a more restrictive CSP for untrusted content
* fix(rewrite): remove obsolete rule for `webtoons.com`
* fix(processor): add missing quotation marks to import comments
* fix(googlereader): return enclosures in the `streamItemContentsHandler` response
* fix: address minor issues detected by Go linters
* feat(urlcleaner): add more Google Analytics parameters
* feat(storage): reduce the number of SQL queries when fetching entry enclosures
* feat(sanitizer): allow the `<u>` tag in feeds
* feat(sanitizer): allow the `<b>` tag
* feat(locale): update Polish translation
* feat(locale): add Romanian translation
* feat(integrations/ntfy): make ntfy topics configurable per feed
* feat(googlereader): add a feed icon endpoint
* feat: show database size on the about page
* feat: add a `make add string` command to add new localized strings
* docs: update README
* docs: update client README to remove references to deprecated functions
* ci: replace GitHub Issue Markdown templates with YAML forms
* build(deps): bump `golangci/golangci-lint-action` from `6` to `7`
* build(deps): bump `golang.org/x/term` from `0.29.0` to `0.30.0`
* build(deps): bump `golang.org/x/oauth2` from `0.26.0` to` 0.28.0`
* build(deps): bump `golang.org/x/net` from `0.35.0` to `0.38.0`
* build(deps): bump `golang.org/x/image` from `0.24.0` to `0.25.0`
* build(deps): bump `golang.org/x/crypto` from `0.33.0` to `0.36.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.3` to `2.22.4`
* build(deps): bump `github.com/prometheus/client_golang`
* build(deps): bump `github.com/golang-jwt/jwt/v5` from `5.2.1` to `5.2.2`
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.11.2` to `0.12.2`
* build(deps): bump `github.com/go-jose/go-jose/v4` from `4.0.2` to `4.0.5`
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.12.0` to `3.13.0`
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)
--------------------------------
* tests(js): improve `.jshintrc` (strict comparison, etc...)
* 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
+11 -1
View File
@@ -116,6 +116,16 @@ run:
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
.PHONY: add-string
add-string:
cd internal/locale/translations && \
for file in *.json; do \
jq --indent 4 --arg key "$(KEY)" --arg val "$(VAL)" \
'. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' "$$file" > tmp && \
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
@@ -134,7 +144,7 @@ integration-test:
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
DEBUG=1 \
LOG_LEVEL=debug \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
+105 -11
View File
@@ -1,20 +1,114 @@
Miniflux 2
==========
Miniflux is a minimalist and opinionated feed reader:
- Written in Go (Golang)
- Works only with Postgresql
- Doesn't use any ORM
- Doesn't use any complicated framework
- Use only modern vanilla Javascript (ES6 and Fetch API)
- Single binary compiled statically without dependency
- The number of features is voluntarily limited
Miniflux is a minimalist and opinionated feed reader.
It's simple, fast, lightweight and super easy to install.
Official website: <https://miniflux.app>
Features
--------
### Feed Reader
- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.
- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.
- Supports multiple attachments (podcasts, videos, music, and images enclosures).
- Plays videos from YouTube directly inside Miniflux.
- Organizes articles using categories and bookmarks.
- Share individual articles publicly.
- Fetches website icons (favicons).
- Saves articles to third-party services.
- Provides full-text search (powered by Postgres).
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy and Security
- Removes pixel trackers.
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
- Retrieves original links when feeds are sourced from FeedBurner.
- Opens external links with attributes `rel="noopener noreferrer" referrerpolicy="no-referrer"` for improved security.
- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.
- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
- Blocks external JavaScript to prevent tracking and enhance security.
- Sanitizes external content before rendering it.
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
### Bot Protection Bypass Mechanisms
- Optionally disable HTTP/2 to mitigate fingerprinting.
- Allows configuration of a custom user agent.
- Supports adding custom cookies for specific use cases.
- Enables the use of proxies for enhanced privacy or bypassing restrictions.
### Content Manipulation
- Fetches the original article and extracts only the relevant content using a local Readability parser.
- Allows custom scraper rules based on <abbr title="Cascading Style Sheets">CSS</abbr> selectors.
- Supports custom rewriting rules for content manipulation.
- Provides a regex filter to include or exclude articles based on specific patterns.
- Optionally permits self-signed or invalid certificates (disabled by default).
- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).
### User Interface
- Optimized stylesheet for readability.
- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.
- Minimalistic and distraction-free user interface.
- No requirement to download an app from Apple App Store or Google Play Store.
- Can be added directly to the home screen for quick access.
- Supports a wide range of keyboard shortcuts for efficient navigation.
- Optional touch gesture support for navigation on mobile devices.
- Custom stylesheets and JavaScript to personalize the user interface to your preferences.
- Themes:
- Light (Sans-Serif)
- Light (Serif)
- Dark (Sans-Serif)
- Dark (Serif)
- System (Sans-Serif) Automatically switches between Dark and Light themes based on system preferences.
- System (Serif)
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).
### Authentication
- Local username and password.
- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).
- Google (OAuth2).
- Generic OpenID Connect.
- Reverse-Proxy authentication.
### Technical Stuff
- Written in [Go (Golang)](https://golang.org/).
- Single binary compiled statically without dependency.
- Works only with [PostgreSQL](https://www.postgresql.org/).
- Does not use any ORM or any complicated frameworks.
- Uses modern vanilla JavaScript only when necessary.
- All static files are bundled into the application binary using the Go `embed` package.
- Supports the Systemd `sd_notify` protocol for process monitoring.
- Configures HTTPS automatically with Let's Encrypt.
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
- Updates feeds in the background using an internal scheduler or a traditional cron job.
- Uses native lazy loading for images and iframes.
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
@@ -29,7 +123,7 @@ The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man
- [Command Line Usage](https://miniflux.app/docs/cli.html)
- [User Interface Usage](https://miniflux.app/docs/ui.html)
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
- [Integration with External Services](https://miniflux.app/docs/services.html)
- [Integration with External Services](https://miniflux.app/docs/#integrations)
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
- [API Reference](https://miniflux.app/docs/api.html)
- [Development](https://miniflux.app/docs/development.html)
+2 -2
View File
@@ -27,10 +27,10 @@ import (
func main() {
// Authentication with username/password:
client := miniflux.New("https://api.example.org", "admin", "secret")
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
// Authentication with an API Key:
client := miniflux.New("https://api.example.org", "my-secret-token")
client := miniflux.NewClient("https://api.example.org", "my-secret-token")
// Fetch all feeds.
feeds, err := client.Feeds()
+75 -4
View File
@@ -18,6 +18,7 @@ type Client struct {
}
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
@@ -179,6 +180,45 @@ func (c *Client) DeleteUser(userID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
}
// APIKeys returns all API keys for the authenticated user.
func (c *Client) APIKeys() (APIKeys, error) {
body, err := c.request.Get("/v1/api-keys")
if err != nil {
return nil, err
}
defer body.Close()
var apiKeys APIKeys
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKeys, nil
}
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
Description: description,
})
if err != nil {
return nil, err
}
defer body.Close()
var apiKey *APIKey
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKey, nil
}
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
@@ -238,8 +278,8 @@ func (c *Client) Categories() (Categories, error) {
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
body, err := c.request.Post("/v1/categories", map[string]interface{}{
"title": title,
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
Title: title,
})
if err != nil {
return nil, err
@@ -254,10 +294,25 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
return category, nil
}
// CreateCategoryWithOptions creates a new category with options.
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
body, err := c.request.Post("/v1/categories", createRequest)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategory updates a category.
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), map[string]interface{}{
"title": title,
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
})
if err != nil {
return nil, err
@@ -272,6 +327,22 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
return category, nil
}
// UpdateCategoryWithOptions updates a category with options.
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// MarkCategoryAsRead marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
+108 -59
View File
@@ -17,35 +17,37 @@ const (
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
@@ -63,33 +65,35 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
@@ -97,9 +101,12 @@ type Users []User
// Category represents a feed category.
type Category struct {
ID int64 `json:"id,omitempty"`
Title string `json:"title,omitempty"`
UserID int64 `json:"user_id,omitempty"`
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id,omitempty"`
HideGlobally bool `json:"hide_globally,omitempty"`
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c Category) String() string {
@@ -109,6 +116,18 @@ func (c Category) String() string {
// Categories represents a list of categories.
type Categories []*Category
// CategoryCreationRequest represents the request to create a category.
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
// CategoryModificationRequest represents the request to update a category.
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
// Subscription represents a feed subscription.
type Subscription struct {
Title string `json:"title"`
@@ -141,8 +160,11 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
@@ -151,6 +173,7 @@ type Feed struct {
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedCreationRequest represents the request to create a feed.
@@ -168,10 +191,14 @@ type FeedCreationRequest struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedModificationRequest represents the request to update a feed.
@@ -181,8 +208,11 @@ type FeedModificationRequest struct {
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
@@ -195,6 +225,7 @@ type FeedModificationRequest struct {
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// FeedIcon represents the feed icon.
@@ -307,6 +338,24 @@ type VersionResponse struct {
OS string `json:"os"`
}
// APIKey represents an application API key.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API keys.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create an API key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
func SetOptionalField[T any](value T) *T {
return &value
}
+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,
}
}
+25 -24
View File
@@ -1,48 +1,49 @@
module miniflux.app/v2
// +heroku goVersion go1.23
// +heroku goVersion go1.24
require (
github.com/PuerkitoBio/goquery v1.10.1
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.12.0
github.com/go-webauthn/webauthn v0.11.2
github.com/coreos/go-oidc/v3 v3.14.1
github.com/go-webauthn/webauthn v0.13.0
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.3
golang.org/x/crypto v0.32.0
golang.org/x/image v0.23.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/term v0.28.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/prometheus/client_golang v1.22.0
github.com/tdewolff/minify/v2 v2.23.8
golang.org/x/crypto v0.39.0
golang.org/x/image v0.28.0
golang.org/x/net v0.41.0
golang.org/x/oauth2 v0.30.0
golang.org/x/term v0.32.0
)
require (
github.com/go-webauthn/x v0.1.14 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/go-tpm v0.9.1 // indirect
github.com/go-webauthn/x v0.1.21 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.5 // indirect
)
require (
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
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
)
go 1.23
go 1.24.0
toolchain go1.24.1
+50 -49
View File
@@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
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.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@@ -8,59 +8,59 @@ 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.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
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=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
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/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/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=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
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.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=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.23.8 h1:tvjHzRer46kwOfpdCBCWsDblCw3QtnLJRd61pTVkyZ8=
github.com/tdewolff/minify/v2 v2.23.8/go.mod h1:VW3ISUd3gDOZuQ/jwZr4sCzsuX+Qvsx87FDMjk6Rvno=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -72,10 +72,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
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=
@@ -90,10 +90,10 @@ 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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
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=
@@ -112,8 +112,8 @@ 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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
@@ -123,8 +123,8 @@ 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.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
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=
@@ -133,8 +133,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
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=
@@ -142,7 +143,7 @@ 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=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3
View File
@@ -76,6 +76,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
}
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
+244 -3
View File
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"math/rand/v2"
"os"
"strings"
"testing"
@@ -48,7 +48,7 @@ func newIntegrationTestConfig() *integrationTestConfig {
testFeedURL: getDefaultEnvValues("TEST_MINIFLUX_FEED_URL", "https://miniflux.app/feed.xml"),
testFeedTitle: getDefaultEnvValues("TEST_MINIFLUX_FEED_TITLE", "Miniflux"),
testSubscriptionTitle: getDefaultEnvValues("TEST_MINIFLUX_SUBSCRIPTION_TITLE", "Miniflux Releases"),
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app"),
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app/"),
}
}
@@ -729,6 +729,116 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
}
}
func TestAPIKeysEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
apiKeys, err := regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
}
// Create an API key for the user.
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
if err != nil {
t.Fatal(err)
}
if apiKey.ID == 0 {
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
}
if apiKey.UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
}
if apiKey.Token == "" {
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
}
if apiKey.Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
}
// Create a duplicate API key with the same description.
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
}
// Fetch the API keys again.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 1 {
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
}
if apiKeys[0].ID != apiKey.ID {
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
}
if apiKeys[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
}
if apiKeys[0].Token != apiKey.Token {
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
}
if apiKeys[0].Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
}
// Create a new client using the API key.
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
// Fetch the user using the API key client.
user, err := apiKeyClient.Me()
if err != nil {
t.Fatal(err)
}
// Verify the user matches the regular test user.
if user.ID != regularTestUser.ID {
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
}
// Delete the API key.
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
t.Fatal(err)
}
// Verify the API key is deleted.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
}
// Try to delete the API key again, it should return an error.
err = regularUserClient.DeleteAPIKey(apiKey.ID)
if err == nil {
t.Fatal(`Deleting a non-existent API key should raise an error`)
}
if !errors.Is(err, miniflux.ErrNotFound) {
t.Fatalf(`Expected "not found" error, got %v`, err)
}
// Try to create an API key with an empty description.
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
t.Fatal(`Creating an API key with an empty description should raise an error`)
}
}
func TestMarkUserAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -824,6 +934,10 @@ func TestCreateCategoryEndpoint(t *testing.T) {
if category.Title != categoryName {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, category.Title, categoryName)
}
if category.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
}
func TestCreateCategoryWithEmptyTitle(t *testing.T) {
@@ -865,7 +979,49 @@ func TestCannotCreateDuplicatedCategory(t *testing.T) {
}
}
func TestUpdateCatgoryEndpoint(t *testing.T) {
func TestCreateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
HideGlobally: true,
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
for _, category := range categories {
if category.ID == newCategory.ID {
if category.Title != newCategory.Title {
t.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)
}
if category.HideGlobally != true {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
break
}
}
}
func TestUpdateCategoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
@@ -903,6 +1059,91 @@ func TestUpdateCatgoryEndpoint(t *testing.T) {
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if !updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateInexistingCategory(t *testing.T) {
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, apiKey)
}
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, apiKeys)
}
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
json.NotFound(w, r)
return
}
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
+10 -10
View File
@@ -19,18 +19,18 @@ import (
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
var categoryCreationRequest model.CategoryCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryRequest)
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
if err != nil {
json.ServerError(w, r, err)
return
@@ -54,20 +54,20 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
return
}
var categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
var categoryModificationRequest model.CategoryModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if err != nil {
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
+12
View File
@@ -331,6 +331,18 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, map[string]interface{}{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
return
}
json.OK(w, r, map[string]string{"content": entry.Content})
}
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/validator"
@@ -29,24 +30,29 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
}
var rssbridgeURL string
var rssbridgeToken string
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
rssbridgeToken,
)
if localizedError != nil {
+51 -30
View File
@@ -13,46 +13,49 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
)
const (
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
// Parse parses command line arguments.
func Parse() {
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagResetFeedNextCheckAt bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
@@ -64,6 +67,7 @@ func Parse() {
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
flag.BoolVar(&flagResetFeedNextCheckAt, "reset-feed-next-check-at", false, flagResetNextCheckAtHelp)
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
@@ -189,7 +193,16 @@ func Parse() {
}
if flagResetFeedErrors {
store.ResetFeedErrors()
if err := store.ResetFeedErrors(); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedNextCheckAt {
if err := store.ResetNextCheckAt(); err != nil {
printErrorAndExit(err)
}
return
}
@@ -228,6 +241,14 @@ func Parse() {
createAdminUserFromEnvironmentVariables(store)
}
if config.Opts.HasHTTPClientProxiesConfigured() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
}
}
if flagRefreshFeeds {
refreshFeeds(store)
return
+15 -5
View File
@@ -13,7 +13,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
httpd "miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd"
@@ -33,9 +33,9 @@ func startDaemon(store *storage.Storage) {
runScheduler(store, pool)
}
var httpServer *http.Server
var httpServers []*http.Server
if config.Opts.HasHTTPService() {
httpServer = httpd.StartWebServer(store, pool)
httpServers = server.StartWebServer(store, pool)
}
if config.Opts.HasMetricsCollector() {
@@ -78,8 +78,18 @@ func startDaemon(store *storage.Storage) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if httpServer != nil {
httpServer.Shutdown(ctx)
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, server := range httpServers {
if server != nil {
if err := server.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Process gracefully stopped")
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
}
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
+145 -227
View File
@@ -6,6 +6,7 @@ package config // import "miniflux.app/v2/internal/config"
import (
"bytes"
"os"
"reflect"
"testing"
)
@@ -184,35 +185,6 @@ func TestLogFormatWithInvalidValue(t *testing.T) {
}
}
func TestDebugModeOn(t *testing.T) {
os.Clearenv()
os.Setenv("DEBUG", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "debug" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestDebugModeOff(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "info" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestCustomBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org")
@@ -457,18 +429,18 @@ func TestListenAddr(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
expected := []string{"foobar"}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %v instead of %v`, result, expected)
}
}
func TestListenAddrWithPortDefined(t *testing.T) {
os.Clearenv()
os.Setenv("PORT", "3000")
os.Setenv("LISTEN_ADDR", "foobar")
os.Setenv("LISTEN_ADDR", "foobar") // This should be overridden by PORT
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@@ -476,11 +448,11 @@ func TestListenAddrWithPortDefined(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := ":3000"
expected := []string{":3000"}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected LISTEN_ADDR value when PORT is set, got %v instead of %v`, result, expected)
}
}
@@ -493,11 +465,11 @@ func TestDefaultListenAddrValue(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultListenAddr
expected := []string{defaultListenAddr}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected default LISTEN_ADDR value, got %v instead of %v`, result, expected)
}
}
@@ -1028,6 +1000,41 @@ func TestSchedulerRoundRobin(t *testing.T) {
}
}
func TestDefaultSchedulerRoundRobinMaxIntervalValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultSchedulerRoundRobinMaxInterval
result := opts.SchedulerRoundRobinMaxInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestSchedulerRoundRobinMaxInterval(t *testing.T) {
os.Clearenv()
os.Setenv("SCHEDULER_ROUND_ROBIN_MAX_INTERVAL", "150")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 150
result := opts.SchedulerRoundRobinMaxInterval()
if result != expected {
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestPollingParsingErrorLimit(t *testing.T) {
os.Clearenv()
os.Setenv("POLLING_PARSING_ERROR_LIMIT", "100")
@@ -1431,41 +1438,6 @@ func TestCreateAdmin(t *testing.T) {
}
}
func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
os.Clearenv()
os.Setenv("POCKET_CONSUMER_KEY", "something")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "something"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "default"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestMediaProxyMode(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
@@ -1652,147 +1624,6 @@ func TestMediaProxyPrivateKey(t *testing.T) {
}
}
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
expectedProxyOption := "all"
result := opts.MediaProxyMode()
if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
}
}
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "all"
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
func TestHTTPSOff(t *testing.T) {
os.Clearenv()
@@ -1931,9 +1762,7 @@ func TestParseConfigFile(t *testing.T) {
content := []byte(`
# This is a comment
DEBUG = yes
POCKET_CONSUMER_KEY= >#1234
LOG_LEVEL = debug
Invalid text
`)
@@ -1956,13 +1785,7 @@ Invalid text
}
if opts.LogLevel() != "debug" {
t.Errorf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
expected := ">#1234"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Errorf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
t.Errorf(`Unexpected log level value, got %q`, opts.LogLevel())
}
if err := tmpfile.Close(); err != nil {
@@ -2134,12 +1957,33 @@ func TestYouTubeApiKey(t *testing.T) {
}
}
func TestDefaultYouTubeEmbedUrl(t *testing.T) {
os.Clearenv()
opts, err := NewParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "https://www.youtube-nocookie.com/embed/"
result := opts.YouTubeEmbedUrlOverride()
if result != expected {
t.Fatalf(`Unexpected default value, got %v instead of %v`, result, expected)
}
expected = "www.youtube-nocookie.com"
result = opts.YouTubeEmbedDomain()
if result != expected {
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
}
}
func TestYouTubeEmbedUrlOverride(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
opts, err := NewParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -2150,6 +1994,12 @@ func TestYouTubeEmbedUrlOverride(t *testing.T) {
if result != expected {
t.Fatalf(`Unexpected YOUTUBE_EMBED_URL_OVERRIDE value, got %v instead of %v`, result, expected)
}
expected = "invidious.custom"
result = opts.YouTubeEmbedDomain()
if result != expected {
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
}
}
func TestParseConfigDumpOutput(t *testing.T) {
@@ -2186,3 +2036,71 @@ func TestParseConfigDumpOutput(t *testing.T) {
t.Fatal(err)
}
}
func TestHTTPClientProxies(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_CLIENT_PROXIES", "http://proxy1.example.com,http://proxy2.example.com")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"http://proxy1.example.com", "http://proxy2.example.com"}
result := opts.HTTPClientProxies()
if len(expected) != len(result) {
t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
}
for i, proxy := range expected {
if result[i] != proxy {
t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value at index %d, got %q instead of %q`, i, result[i], proxy)
}
}
}
func TestDefaultHTTPClientProxiesValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{}
result := opts.HTTPClientProxies()
if len(expected) != len(result) {
t.Fatalf(`Unexpected default HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
}
}
func TestHTTPClientProxy(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_CLIENT_PROXY", "http://proxy.example.com")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://proxy.example.com"
if opts.HTTPClientProxyURL() == nil || opts.HTTPClientProxyURL().String() != expected {
t.Fatalf(`Unexpected HTTP_CLIENT_PROXY value, got %v instead of %v`, opts.HTTPClientProxyURL(), expected)
}
}
func TestInvalidHTTPClientProxy(t *testing.T) {
os.Clearenv()
os.Setenv("HTTP_CLIENT_PROXY", "sche|me://invalid-proxy-url")
parser := NewParser()
_, err := parser.ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Expected error for invalid HTTP_CLIENT_PROXY value, but got none`)
}
}
+75 -37
View File
@@ -5,6 +5,7 @@ package config // import "miniflux.app/v2/internal/config"
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
@@ -36,6 +37,7 @@ const (
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60
defaultSchedulerEntryFrequencyFactor = 1
defaultSchedulerRoundRobinMinInterval = 60
defaultSchedulerRoundRobinMaxInterval = 1440
defaultPollingParsingErrorLimit = 3
defaultRunMigrations = false
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
@@ -73,7 +75,6 @@ const (
defaultOauth2OidcProviderName = "OpenID Connect"
defaultOAuth2Provider = ""
defaultDisableLocalAuth = false
defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15
defaultHTTPClientProxy = ""
@@ -110,7 +111,6 @@ type Options struct {
hsts bool
httpService bool
schedulerService bool
serverTimingHeader bool
baseURL string
rootURL string
basePath string
@@ -119,7 +119,7 @@ type Options struct {
databaseMinConns int
databaseConnectionLifetime int
runMigrations bool
listenAddr string
listenAddr []string
certFile string
certDomain string
certKeyFile string
@@ -136,6 +136,7 @@ type Options struct {
schedulerEntryFrequencyMaxInterval int
schedulerEntryFrequencyFactor int
schedulerRoundRobinMinInterval int
schedulerRoundRobinMaxInterval int
pollingParsingErrorLimit int
workerPoolSize int
createAdmin bool
@@ -152,6 +153,7 @@ type Options struct {
filterEntryMaxAgeDays int
youTubeApiKey string
youTubeEmbedUrlOverride string
youTubeEmbedDomain string
oauth2UserCreationAllowed bool
oauth2ClientID string
oauth2ClientSecret string
@@ -160,10 +162,10 @@ type Options struct {
oidcProviderName string
oauth2Provider string
disableLocalAuth bool
pocketConsumerKey string
httpClientTimeout int
httpClientMaxBodySize int64
httpClientProxy string
httpClientProxyURL *url.URL
httpClientProxies []string
httpClientUserAgent string
httpServerTimeout int
authProxyHeader string
@@ -192,7 +194,6 @@ func NewOptions() *Options {
hsts: defaultHSTS,
httpService: defaultHTTPService,
schedulerService: defaultSchedulerService,
serverTimingHeader: defaultTiming,
baseURL: defaultBaseURL,
rootURL: defaultRootURL,
basePath: defaultBasePath,
@@ -201,7 +202,7 @@ func NewOptions() *Options {
databaseMinConns: defaultDatabaseMinConns,
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
runMigrations: defaultRunMigrations,
listenAddr: defaultListenAddr,
listenAddr: []string{defaultListenAddr},
certFile: defaultCertFile,
certDomain: defaultCertDomain,
certKeyFile: defaultKeyFile,
@@ -218,6 +219,7 @@ func NewOptions() *Options {
schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
schedulerEntryFrequencyFactor: defaultSchedulerEntryFrequencyFactor,
schedulerRoundRobinMinInterval: defaultSchedulerRoundRobinMinInterval,
schedulerRoundRobinMaxInterval: defaultSchedulerRoundRobinMaxInterval,
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
@@ -240,10 +242,10 @@ func NewOptions() *Options {
oidcProviderName: defaultOauth2OidcProviderName,
oauth2Provider: defaultOAuth2Provider,
disableLocalAuth: defaultDisableLocalAuth,
pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
httpClientProxy: defaultHTTPClientProxy,
httpClientProxyURL: nil,
httpClientProxies: []string{},
httpClientUserAgent: defaultHTTPClientUserAgent,
httpServerTimeout: defaultHTTPServerTimeout,
authProxyHeader: defaultAuthProxyHeader,
@@ -296,11 +298,6 @@ func (o *Options) MaintenanceMessage() string {
return o.maintenanceMessage
}
// HasServerTimingHeader returns true if server-timing headers enabled.
func (o *Options) HasServerTimingHeader() bool {
return o.serverTimingHeader
}
// BaseURL returns the application base URL with path.
func (o *Options) BaseURL() string {
return o.baseURL
@@ -342,7 +339,7 @@ func (o *Options) DatabaseConnectionLifetime() time.Duration {
}
// ListenAddr returns the listen address for the HTTP server.
func (o *Options) ListenAddr() string {
func (o *Options) ListenAddr() []string {
return o.listenAddr
}
@@ -430,6 +427,10 @@ func (o *Options) SchedulerRoundRobinMinInterval() int {
return o.schedulerRoundRobinMinInterval
}
func (o *Options) SchedulerRoundRobinMaxInterval() int {
return o.schedulerRoundRobinMaxInterval
}
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
func (o *Options) PollingParsingErrorLimit() int {
return o.pollingParsingErrorLimit
@@ -511,11 +512,19 @@ func (o *Options) YouTubeApiKey() string {
return o.youTubeApiKey
}
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
// YouTubeEmbedUrlOverride returns the YouTube embed URL override if defined.
func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// YouTubeEmbedDomain returns the domain used for YouTube embeds.
func (o *Options) YouTubeEmbedDomain() string {
if o.youTubeEmbedDomain != "" {
return o.youTubeEmbedDomain
}
return "www.youtube-nocookie.com"
}
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
@@ -569,14 +578,6 @@ func (o *Options) HasSchedulerService() bool {
return o.schedulerService
}
// PocketConsumerKey returns the Pocket Consumer Key if configured.
func (o *Options) PocketConsumerKey(defaultValue string) string {
if o.pocketConsumerKey != "" {
return o.pocketConsumerKey
}
return defaultValue
}
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
func (o *Options) HTTPClientTimeout() int {
return o.httpClientTimeout
@@ -587,9 +588,24 @@ func (o *Options) HTTPClientMaxBodySize() int64 {
return o.httpClientMaxBodySize
}
// HTTPClientProxy returns the proxy URL for HTTP client.
func (o *Options) HTTPClientProxy() string {
return o.httpClientProxy
// HTTPClientProxyURL returns the client HTTP proxy URL if configured.
func (o *Options) HTTPClientProxyURL() *url.URL {
return o.httpClientProxyURL
}
// HasHTTPClientProxyURLConfigured returns true if the client HTTP proxy URL if configured.
func (o *Options) HasHTTPClientProxyURLConfigured() bool {
return o.httpClientProxyURL != nil
}
// HTTPClientProxies returns the list of proxies.
func (o *Options) HTTPClientProxies() []string {
return o.httpClientProxies
}
// HTTPClientProxiesString returns true if the list of rotating proxies are configured.
func (o *Options) HasHTTPClientProxiesConfigured() bool {
return len(o.httpClientProxies) > 0
}
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
@@ -597,11 +613,6 @@ func (o *Options) HTTPServerTimeout() int {
return o.httpServerTimeout
}
// HasHTTPClientProxyConfigured returns true if the HTTP proxy is configured.
func (o *Options) HasHTTPClientProxyConfigured() bool {
return o.httpClientProxy != ""
}
// AuthProxyHeader returns an HTTP header name that contains username for
// authentication using auth proxy.
func (o *Options) AuthProxyHeader() string {
@@ -664,6 +675,33 @@ func (o *Options) FilterEntryMaxAgeDays() int {
// SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option {
var clientProxyURLRedacted string
if o.httpClientProxyURL != nil {
if redactSecret {
clientProxyURLRedacted = o.httpClientProxyURL.Redacted()
} else {
clientProxyURLRedacted = o.httpClientProxyURL.String()
}
}
var clientProxyURLsRedacted string
if len(o.httpClientProxies) > 0 {
if redactSecret {
var proxyURLs []string
for range o.httpClientProxies {
proxyURLs = append(proxyURLs, "<redacted>")
}
clientProxyURLsRedacted = strings.Join(proxyURLs, ",")
} else {
clientProxyURLsRedacted = strings.Join(o.httpClientProxies, ",")
}
}
var mediaProxyPrivateKeyValue string
if len(o.mediaProxyPrivateKey) > 0 {
mediaProxyPrivateKeyValue = "<binary-data>"
}
var keyValues = map[string]interface{}{
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
"ADMIN_USERNAME": o.adminUsername,
@@ -694,14 +732,15 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"FETCH_BILIBILI_WATCH_TIME": o.fetchBilibiliWatchTime,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
"HTTP_CLIENT_PROXY": o.httpClientProxy,
"HTTP_CLIENT_PROXIES": clientProxyURLsRedacted,
"HTTP_CLIENT_PROXY": clientProxyURLRedacted,
"HTTP_CLIENT_TIMEOUT": o.httpClientTimeout,
"HTTP_CLIENT_USER_AGENT": o.httpClientUserAgent,
"HTTP_SERVER_TIMEOUT": o.httpServerTimeout,
"HTTP_SERVICE": o.httpService,
"INVIDIOUS_INSTANCE": o.invidiousInstance,
"KEY_FILE": o.certKeyFile,
"LISTEN_ADDR": o.listenAddr,
"LISTEN_ADDR": strings.Join(o.listenAddr, ","),
"LOG_FILE": o.logFile,
"LOG_DATE_TIME": o.logDateTime,
"LOG_FORMAT": o.logFormat,
@@ -721,7 +760,6 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
"POLLING_FREQUENCY": o.pollingFrequency,
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
@@ -729,7 +767,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes,
"MEDIA_PROXY_MODE": o.mediaProxyMode,
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret),
"MEDIA_PROXY_PRIVATE_KEY": mediaProxyPrivateKeyValue,
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
"ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations,
@@ -737,8 +775,8 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": o.schedulerEntryFrequencyMinInterval,
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": o.schedulerEntryFrequencyFactor,
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": o.schedulerRoundRobinMinInterval,
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": o.schedulerRoundRobinMaxInterval,
"SCHEDULER_SERVICE": o.schedulerService,
"SERVER_TIMING_HEADER": o.serverTimingHeader,
"WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize,
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
+24 -40
View File
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@@ -87,14 +86,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
if parsedValue == "json" || parsedValue == "text" {
p.opts.logFormat = parsedValue
}
case "DEBUG":
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
}
case "SERVER_TIMING_HEADER":
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
@@ -103,7 +94,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseString(value, defaultListenAddr)
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_URL_FILE":
@@ -160,40 +151,21 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "PROXY_IMAGES":
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_HTTP_CLIENT_TIMEOUT":
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "PROXY_OPTION":
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_MEDIA_TYPES":
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "PROXY_IMAGE_URL":
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_URL":
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_PRIVATE_KEY":
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
if _, err := rand.Read(randomKey); err != nil {
return fmt.Errorf("config: unable to generate random key: %w", err)
}
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
@@ -207,10 +179,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.adminPassword = parseString(value, defaultAdminPassword)
case "ADMIN_PASSWORD_FILE":
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
case "POCKET_CONSUMER_KEY":
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
case "POCKET_CONSUMER_KEY_FILE":
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
@@ -236,7 +204,12 @@ func (p *Parser) parseLines(lines []string) (err error) {
case "HTTP_CLIENT_MAX_BODY_SIZE":
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
case "HTTP_CLIENT_PROXY":
p.opts.httpClientProxy = parseString(value, defaultHTTPClientProxy)
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
if err != nil {
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
}
case "HTTP_CLIENT_PROXIES":
p.opts.httpClientProxies = parseStringList(value, []string{})
case "HTTP_CLIENT_USER_AGENT":
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
case "HTTP_SERVER_TIMEOUT":
@@ -285,8 +258,15 @@ func (p *Parser) parseLines(lines []string) (err error) {
}
if port != "" {
p.opts.listenAddr = ":" + port
p.opts.listenAddr = []string{":" + port}
}
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
if err != nil {
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
}
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
return nil
}
@@ -359,6 +339,10 @@ func parseStringList(value string, fallback []string) []string {
for _, item := range items {
itemValue := strings.TrimSpace(item)
if itemValue == "" {
continue
}
if _, found := strMap[itemValue]; !found {
strMap[itemValue] = true
strList = append(strList, itemValue)
+106
View File
@@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"reflect"
"testing"
)
@@ -58,3 +59,108 @@ func TestParseIntValue(t *testing.T) {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseListenAddr(t *testing.T) {
defaultExpected := []string{defaultListenAddr}
tests := []struct {
name string
listenAddr string
port string
expected []string
lines []string // Used for direct lines parsing instead of individual env vars
isLineOriented bool // Flag to indicate if we use lines
}{
{
name: "Single LISTEN_ADDR",
listenAddr: "127.0.0.1:8080",
expected: []string{"127.0.0.1:8080"},
},
{
name: "Multiple LISTEN_ADDR comma-separated",
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
},
{
name: "Multiple LISTEN_ADDR with spaces around commas",
listenAddr: "127.0.0.1:8080 , :8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "Empty LISTEN_ADDR",
listenAddr: "",
expected: defaultExpected,
},
{
name: "PORT overrides LISTEN_ADDR",
listenAddr: "127.0.0.1:8000",
port: "8082",
expected: []string{":8082"},
},
{
name: "PORT overrides empty LISTEN_ADDR",
listenAddr: "",
port: "8083",
expected: []string{":8083"},
},
{
name: "LISTEN_ADDR with empty segment (comma)",
listenAddr: "127.0.0.1:8080,,:8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "PORT override with lines parsing",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
expected: []string{":8082"},
},
{
name: "LISTEN_ADDR only with lines parsing (comma)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
},
{
name: "Empty LISTEN_ADDR with lines parsing (default)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR="},
expected: defaultExpected,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
var err error
if tt.isLineOriented {
err = parser.parseLines(tt.lines)
} else {
// Simulate os.Environ() behaviour for individual var testing
var envLines []string
if tt.listenAddr != "" {
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
}
if tt.port != "" {
envLines = append(envLines, "PORT="+tt.port)
}
// Add a dummy var if both are empty to avoid empty lines slice if not intended
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
// This case specifically tests empty LISTEN_ADDR resulting in default
// So, we pass LISTEN_ADDR=
envLines = append(envLines, "LISTEN_ADDR=")
}
err = parser.parseLines(envLines)
}
if err != nil {
t.Fatalf("parseLines() error = %v", err)
}
opts := parser.opts
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
}
})
}
}
+10 -15
View File
@@ -8,38 +8,33 @@ import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"hash/fnv"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a SHA-256 checksum of the input.
// HashFromBytes returns a non-cryptographic checksum of the input.
func HashFromBytes(value []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(value))
h := fnv.New128a()
h.Write(value)
return hex.EncodeToString(h.Sum(nil))
}
// Hash returns a SHA-256 checksum of a string.
func Hash(value string) string {
return HashFromBytes([]byte(value))
// SHA256 returns a SHA-256 checksum of a string.
func SHA256(value string) string {
h := sha256.Sum256([]byte(value))
return hex.EncodeToString(h[:])
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
panic(err)
}
rand.Read(b)
return b
}
// GenerateRandomString returns a random string.
func GenerateRandomString(size int) string {
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
+1 -1
View File
@@ -34,7 +34,7 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
+257 -97
View File
@@ -5,6 +5,8 @@ package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"miniflux.app/v2/internal/crypto"
)
var schemaVersion = len(migrations)
@@ -209,12 +211,13 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN wallabag_client_id text default '';
ALTER TABLE integrations ADD COLUMN wallabag_client_secret text default '';
ALTER TABLE integrations ADD COLUMN wallabag_username text default '';
ALTER TABLE integrations ADD COLUMN wallabag_password text default '';
ALTER TABLE integrations
ADD COLUMN wallabag_enabled bool default 'f',
ADD COLUMN wallabag_url text default '',
ADD COLUMN wallabag_client_id text default '',
ADD COLUMN wallabag_client_secret text default '',
ADD COLUMN wallabag_username text default '',
ADD COLUMN wallabag_password text default '';
`
_, err = tx.Exec(sql)
return err
@@ -234,9 +237,10 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN nunux_keeper_api_key text default '';
ALTER TABLE integrations
ADD COLUMN nunux_keeper_enabled bool default 'f',
ADD COLUMN nunux_keeper_url text default '',
ADD COLUMN nunux_keeper_api_key text default '';
`
_, err = tx.Exec(sql)
return err
@@ -253,9 +257,10 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN pocket_consumer_key text default '';
ALTER TABLE integrations
ADD COLUMN pocket_enabled bool default 'f',
ADD COLUMN pocket_access_token text default '',
ADD COLUMN pocket_consumer_key text default '';
`
_, err = tx.Exec(sql)
return err
@@ -269,8 +274,9 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE feeds
ADD COLUMN username text default '',
ADD COLUMN password text default '';
`
_, err = tx.Exec(sql)
return err
@@ -507,7 +513,7 @@ var migrations = []func(tx *sql.Tx, driver string) 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_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
CREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);
`)
@@ -556,9 +562,10 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN telegram_bot_chat_id text default '';
ALTER TABLE integrations
ADD COLUMN telegram_bot_enabled bool default 'f',
ADD COLUMN telegram_bot_token text default '',
ADD COLUMN telegram_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return err
@@ -573,28 +580,31 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN googlereader_password text default '';
ALTER TABLE integrations
ADD COLUMN googlereader_enabled bool default 'f',
ADD COLUMN googlereader_username text default '',
ADD COLUMN googlereader_password text default '';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN espial_api_key text default '';
ALTER TABLE integrations ADD COLUMN espial_tags text default 'miniflux';
ALTER TABLE integrations
ADD COLUMN espial_enabled bool default 'f',
ADD COLUMN espial_url text default '',
ADD COLUMN espial_api_key text default '',
ADD COLUMN espial_tags text default 'miniflux';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN linkding_api_key text default '';
ALTER TABLE integrations
ADD COLUMN linkding_enabled bool default 'f',
ADD COLUMN linkding_url text default '',
ADD COLUMN linkding_api_key text default '';
`
_, err = tx.Exec(sql)
return err
@@ -607,8 +617,9 @@ var migrations = []func(tx *sql.Tx, driver string) 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;
ALTER TABLE users
ADD COLUMN default_reading_speed int default 265,
ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
@@ -632,11 +643,12 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN matrix_bot_password text default '';
ALTER TABLE integrations ADD COLUMN matrix_bot_url text default '';
ALTER TABLE integrations ADD COLUMN matrix_bot_chat_id text default '';
ALTER TABLE integrations
ADD COLUMN matrix_bot_enabled bool default 'f',
ADD COLUMN matrix_bot_user text default '',
ADD COLUMN matrix_bot_password text default '',
ADD COLUMN matrix_bot_url text default '',
ADD COLUMN matrix_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return
@@ -655,8 +667,9 @@ var migrations = []func(tx *sql.Tx, driver string) 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;
ALTER TABLE users ALTER COLUMN gesture_nav SET default 'tap';
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,
ALTER COLUMN gesture_nav SET default 'tap';
`
_, err = tx.Exec(sql)
return err
@@ -718,45 +731,50 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN notion_page_id text default '';
ALTER TABLE integrations
ADD COLUMN notion_enabled bool default 'f',
ADD COLUMN notion_token text default '',
ADD COLUMN notion_page_id text default '';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations
ADD COLUMN readwise_enabled bool default 'f',
ADD COLUMN readwise_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN apprise_services_url text default '';
ALTER TABLE integrations
ADD COLUMN apprise_enabled bool default 'f',
ADD COLUMN apprise_url text default '',
ADD COLUMN apprise_services_url text default '';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN shiori_username text default '';
ALTER TABLE integrations ADD COLUMN shiori_password text default '';
ALTER TABLE integrations
ADD COLUMN shiori_enabled bool default 'f',
ADD COLUMN shiori_url text default '',
ADD COLUMN shiori_username text default '',
ADD COLUMN shiori_password text default '';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN shaarli_api_secret text default '';
ALTER TABLE integrations
ADD COLUMN shaarli_enabled bool default 'f',
ADD COLUMN shaarli_url text default '',
ADD COLUMN shaarli_api_secret text default '';
`
_, err = tx.Exec(sql)
return err
@@ -769,18 +787,20 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN webhook_secret text default '';
ALTER TABLE integrations
ADD COLUMN webhook_enabled bool default 'f',
ADD COLUMN webhook_url text default '',
ADD COLUMN webhook_secret text default '';
`
_, err = tx.Exec(sql)
return err
},
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';
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_notification bool default 'f';
ALTER TABLE integrations
ADD COLUMN telegram_bot_topic_id int,
ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f',
ADD COLUMN telegram_bot_disable_notification bool default 'f';
`
_, err = tx.Exec(sql)
return err
@@ -810,8 +830,9 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations
ADD COLUMN rssbridge_enabled bool default 'f',
ADD COLUMN rssbridge_url text default '';
`
_, err = tx.Exec(sql)
return
@@ -836,41 +857,45 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN omnivore_url text default '';
ALTER TABLE integrations
ADD COLUMN omnivore_enabled bool default 'f',
ADD COLUMN omnivore_api_key text default '',
ADD COLUMN omnivore_url text default '';
`
_, err = tx.Exec(sql)
return
},
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 '';
ALTER TABLE integrations ADD COLUMN linkace_api_key text default '';
ALTER TABLE integrations ADD COLUMN linkace_tags text default '';
ALTER TABLE integrations ADD COLUMN linkace_is_private bool default 't';
ALTER TABLE integrations ADD COLUMN linkace_check_disabled bool default 't';
ALTER TABLE integrations
ADD COLUMN linkace_enabled bool default 'f',
ADD COLUMN linkace_url text default '',
ADD COLUMN linkace_api_key text default '',
ADD COLUMN linkace_tags text default '',
ADD COLUMN linkace_is_private bool default 't',
ADD COLUMN linkace_check_disabled bool default 't';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN linkwarden_api_key text default '';
ALTER TABLE integrations
ADD COLUMN linkwarden_enabled bool default 'f',
ADD COLUMN linkwarden_url text default '',
ADD COLUMN linkwarden_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
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';
ALTER TABLE integrations ADD COLUMN readeck_url text default '';
ALTER TABLE integrations ADD COLUMN readeck_api_key text default '';
ALTER TABLE integrations ADD COLUMN readeck_labels text default '';
ALTER TABLE integrations
ADD COLUMN readeck_enabled bool default 'f',
ADD COLUMN readeck_only_url bool default 'f',
ADD COLUMN readeck_url text default '',
ADD COLUMN readeck_api_key text default '',
ADD COLUMN readeck_labels text default '';
`
_, err = tx.Exec(sql)
return err
@@ -899,10 +924,11 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
ALTER TABLE integrations
ADD COLUMN raindrop_enabled bool default 'f',
ADD COLUMN raindrop_token text default '',
ADD COLUMN raindrop_collection_id text default '',
ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
@@ -923,25 +949,28 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations ADD COLUMN betula_enabled bool default 'f';
ALTER TABLE integrations
ADD COLUMN betula_url text default '',
ADD COLUMN betula_token text default '',
ADD COLUMN betula_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
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 '';
ALTER TABLE integrations ADD COLUMN ntfy_topic text default '';
ALTER TABLE integrations ADD COLUMN ntfy_api_token text default '';
ALTER TABLE integrations ADD COLUMN ntfy_username text default '';
ALTER TABLE integrations ADD COLUMN ntfy_password text default '';
ALTER TABLE integrations ADD COLUMN ntfy_icon_url text default '';
ALTER TABLE integrations
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_url text default '',
ADD COLUMN ntfy_topic text default '',
ADD COLUMN ntfy_api_token text default '',
ADD COLUMN ntfy_username text default '',
ADD COLUMN ntfy_password text default '',
ADD COLUMN ntfy_icon_url text default '';
ALTER TABLE feeds ADD COLUMN ntfy_enabled bool default 'f';
ALTER TABLE feeds ADD COLUMN ntfy_priority int default '3';
ALTER TABLE feeds
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_priority int default '3';
`
_, err = tx.Exec(sql)
return err
@@ -963,16 +992,18 @@ var migrations = []func(tx *sql.Tx, driver string) 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 '';
ALTER TABLE integrations
ADD COLUMN cubox_enabled bool default 'f',
ADD COLUMN cubox_api_link text default '';
`
_, 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 '';
ALTER TABLE integrations
ADD COLUMN discord_enabled bool default 'f',
ADD COLUMN discord_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
@@ -982,4 +1013,133 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN slack_enabled bool default 'f',
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',
ADD COLUMN pushover_user text default '',
ADD COLUMN pushover_token text default '',
ADD COLUMN pushover_device text default '',
ADD COLUMN pushover_prefix text default '';
ALTER TABLE feeds
ADD COLUMN pushover_enabled bool default 'f',
ADD COLUMN pushover_priority int default '0';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE icons ADD COLUMN external_id text default '';
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`
DECLARE id_cursor CURSOR FOR
SELECT
id
FROM icons
WHERE external_id = ''
FOR UPDATE`)
if err != nil {
return err
}
defer tx.Exec("CLOSE id_cursor")
for {
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err = tx.Exec(
`
UPDATE icons SET external_id = $1 WHERE id = $2
`,
crypto.GenerateRandomStringHex(20), id)
if err != nil {
return err
}
}
return nil
},
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_token text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN karakeep_enabled bool default 'f',
ADD COLUMN karakeep_api_key text default '',
ADD COLUMN karakeep_url text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE integrations
DROP COLUMN pocket_enabled,
DROP COLUMN pocket_access_token,
DROP COLUMN pocket_consumer_key;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN block_filter_entry_rules text not null default '',
ADD COLUMN keep_filter_entry_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
}
+176 -333
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/config"
@@ -20,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription"
@@ -34,178 +34,12 @@ type handler struct {
router *mux.Router
}
const (
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
StreamPrefix = "user/-/state/com.google/"
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
UserStreamPrefix = "user/%d/state/com.google/"
// LabelPrefix is the prefix for a label stream
LabelPrefix = "user/-/label/"
// UserLabelPrefix is the user specific prefix prefix for a label stream
UserLabelPrefix = "user/%d/label/"
// FeedPrefix is the prefix for a feed stream
FeedPrefix = "feed/"
// Read is the suffix for read stream
Read = "read"
// Starred is the suffix for starred stream
Starred = "starred"
// ReadingList is the suffix for reading list stream
ReadingList = "reading-list"
// KeptUnread is the suffix for kept unread stream
KeptUnread = "kept-unread"
// Broadcast is the suffix for broadcast stream
Broadcast = "broadcast"
// BroadcastFriends is the suffix for broadcast friends stream
BroadcastFriends = "broadcast-friends"
// Like is the suffix for like stream
Like = "like"
// EntryIDLong is the long entry id representation
EntryIDLong = "tag:google.com,2005:reader/item/%016x"
var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
)
const (
// ParamItemIDs - name of the parameter with the item ids
ParamItemIDs = "i"
// ParamStreamID - name of the parameter containing the stream to be included
ParamStreamID = "s"
// ParamStreamExcludes - name of the parameter containing streams to be excluded
ParamStreamExcludes = "xt"
// ParamStreamFilters - name of the parameter containing streams to be included
ParamStreamFilters = "it"
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
ParamStreamMaxItems = "n"
// ParamStreamOrder - name of the parameter containing the sort criteria
ParamStreamOrder = "r"
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
ParamStreamStartTime = "ot"
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
ParamStreamStopTime = "nt"
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
ParamTagsRemove = "r"
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
ParamTagsAdd = "a"
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
ParamSubscribeAction = "ac"
// ParamTitle - name of the parameter for the title of the subscription
ParamTitle = "t"
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
ParamQuickAdd = "quickadd"
// ParamDestination - name of the parameter for the new name of a tag
ParamDestination = "dest"
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
ParamContinuation = "c"
)
// StreamType represents the possible stream types
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
// RequestModifiers are the parsed request parameters.
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
// Serve handles Google Reader API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
@@ -227,102 +61,22 @@ func Serve(router *mux.Router, store *storage.Storage) {
sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
sr.HandleFunc("/mark-all-as-read", handler.markAllAsReadHandler).Methods(http.MethodPost).Name("MarkAllAsRead")
sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
}
func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
return result, nil
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix)
switch id {
case Read:
return Stream{ReadStream, ""}, nil
case Starred:
return Stream{StarredStream, ""}, nil
case ReadingList:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
tags := make(map[StreamType]bool)
for _, s := range addTags {
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = false
case StarredStream:
@@ -337,12 +91,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", KeptUnread, Read)
}
tags[ReadStream] = true
case StarredStream:
@@ -360,28 +114,6 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
return tags, nil
}
func getItemIDs(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
var itemID int64
_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
if err != nil {
itemID, err = strconv.ParseInt(item, 16, 64)
if err != nil {
return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
}
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
@@ -558,9 +290,9 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -683,14 +415,16 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
var rssBridgeURL string
var rssBridgeToken string
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
rssBridgeURL = intg.RSSBridgeURL
rssBridgeToken = intg.RSSBridgeToken
}
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL)
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
@@ -735,17 +469,16 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
return store.FeedByID(userID, feedID)
}
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
func getOrCreateCategory(streamCategory Stream, store *storage.Storage, userID int64) (*model.Category, error) {
switch {
case category.ID == "":
case streamCategory.ID == "":
return store.FirstCategory(userID)
case store.CategoryTitleExists(userID, category.ID):
return store.CategoryByTitle(userID, category.ID)
case store.CategoryTitleExists(userID, streamCategory.ID):
return store.CategoryByTitle(userID, streamCategory.ID)
default:
catRequest := model.CategoryRequest{
Title: category.ID,
}
return store.CreateCategory(userID, &catRequest)
return store.CreateCategory(userID, &model.CategoryCreationRequest{
Title: streamCategory.ID,
})
}
}
@@ -796,14 +529,25 @@ func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
return nil
}
func rename(stream Stream, title string, store *storage.Storage, userID int64) error {
func rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Renaming feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.String("new_title", title),
)
if title == "" {
return errors.New("empty title")
return errEmptyFeedTitle
}
feed, err := getFeed(stream, store, userID)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
if feed == nil {
return errFeedNotFound
}
feedModification := model.FeedModificationRequest{
Title: &title,
}
@@ -811,15 +555,29 @@ func rename(stream Stream, title string, store *storage.Storage, userID int64) e
return store.UpdateFeed(feed)
}
func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
feed, err := getFeed(stream, store, userID)
func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Moving feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.Any("label_stream", labelStream),
)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
category, err := getOrCreateCategory(destination, store, userID)
if feed == nil {
return errFeedNotFound
}
category, err := getOrCreateCategory(labelStream, store, userID)
if err != nil {
return err
}
if category == nil {
return errCategoryNotFound
}
feedModification := model.FeedModificationRequest{
CategoryID: &category.ID,
}
@@ -827,6 +585,14 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6
return store.UpdateFeed(feed)
}
func (h *handler) feedIconURL(f *model.Feed) string {
if f.Icon != nil && f.Icon.ExternalIconID != "" {
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
} else {
return ""
}
}
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
@@ -874,7 +640,11 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
case "edit":
if title != "" {
if err := rename(streamIds[0], title, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
@@ -886,12 +656,16 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
}
if err := move(streamIds[0], newLabel, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
default:
json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
json.BadRequest(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return
}
@@ -900,6 +674,7 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
userName := request.UserName(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /stream/items/contents",
@@ -919,13 +694,8 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
json.ServerError(w, r, err)
return
}
var user *model.User
if user, err = h.store.UserByID(userID); err != nil {
json.ServerError(w, r, err)
return
}
requestModifiers, err := getStreamFilterModifiers(r)
requestModifiers, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
@@ -935,9 +705,9 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -950,6 +720,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
@@ -960,28 +731,17 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
if len(entries) == 0 {
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
return
}
result := streamContentItems{
Direction: "ltr",
ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
Title: entries[0].Feed.Title,
Alternate: []contentHREFType{
{
HREF: entries[0].Feed.SiteURL,
Type: "text/html",
},
},
Updated: time.Now().Unix(),
ID: "user/-/state/com.google/reading-list",
Title: "Reading List",
Updated: time.Now().Unix(),
Self: []contentHREF{
{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
},
},
Author: user.Username,
Author: userName,
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
@@ -1007,7 +767,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
entry.Enclosures.ProxifyEnclosureURL(h.router)
contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID),
ID: convertEntryIDToLongFormItemID(entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
@@ -1134,20 +894,23 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
json.NotFound(w, r)
return
}
categoryRequest := model.CategoryRequest{
Title: destination.ID,
categoryModificationRequest := model.CategoryModificationRequest{
Title: model.SetOptionalField(destination.ID),
}
verr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest)
if verr != nil {
json.BadRequest(w, r, verr.Error())
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
json.BadRequest(w, r, validationError.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if err != nil {
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
OK(w, r)
}
@@ -1207,6 +970,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
json.ServerError(w, r, err)
return
}
result.Subscriptions = make([]subscription, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscription{
@@ -1215,7 +979,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: "", // TODO: Icons are base64 encoded in the DB.
IconURL: h.feedIconURL(feed),
})
}
json.OK(w, r, result)
@@ -1272,7 +1036,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
return
}
rm, err := getStreamFilterModifiers(r)
rm, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
@@ -1499,3 +1263,82 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /mark-all-as-read",
slog.String("handler", "markAllAsReadHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
json.BadRequest(w, r, err)
return
}
stream, err := getStream(r.Form.Get(ParamStreamID), userID)
if err != nil {
json.BadRequest(w, r, err)
return
}
var before time.Time
if timestampParamValue := r.Form.Get(ParamTimestamp); timestampParamValue != "" {
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
if timestampParsedValue > 0 {
// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.
if len(timestampParamValue) >= 16 {
before = time.UnixMicro(timestampParsedValue)
} else {
before = time.Unix(timestampParsedValue, 0)
}
}
}
if before.IsZero() {
before = time.Now()
}
switch stream.Type {
case FeedStream:
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
err = h.store.MarkFeedAsRead(userID, feedID, before)
if err != nil {
json.ServerError(w, r, err)
return
}
case LabelStream:
category, err := h.store.CategoryByTitle(userID, stream.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {
json.ServerError(w, r, err)
return
}
case ReadingListStream:
if err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {
json.ServerError(w, r, err)
return
}
}
OK(w, r)
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strconv"
"strings"
)
const (
ItemIDPrefix = "tag:google.com,2005:reader/item/"
ItemIDFormat = "tag:google.com,2005:reader/item/%016x"
)
func convertEntryIDToLongFormItemID(entryID int64) string {
// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.
return fmt.Sprintf(ItemIDFormat, entryID)
}
// Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
// NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
// Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
// Liferea uses this format: "12345" (decimal string)
// It returns the parsed ID as a int64 and an error if parsing fails.
func parseItemID(itemIDValue string) (int64, error) {
var itemID int64
if strings.HasPrefix(itemIDValue, ItemIDPrefix) {
n, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)
if err != nil {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: %w", itemIDValue, err)
}
if n != 1 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: expected 1 value, got %d", itemIDValue, n)
}
if itemID == 0 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: item ID is zero", itemIDValue)
}
return itemID, nil
}
if len(itemIDValue) == 16 {
if n, err := fmt.Sscanf(itemIDValue, "%016x", &itemID); err == nil && n == 1 {
return itemID, nil
}
}
itemID, err := strconv.ParseInt(itemIDValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse decimal item ID %s: %w", itemIDValue, err)
}
return itemID, nil
}
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
itemID, err := parseItemID(item)
if err != nil {
return nil, fmt.Errorf("googlereader: failed to parse item ID %s: %w", item, err)
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"net/http"
"net/url"
"reflect"
"testing"
)
func TestConvertEntryIDToLongFormItemID(t *testing.T) {
entryID := int64(344691561)
expected := "tag:google.com,2005:reader/item/00000000148b9369"
result := convertEntryIDToLongFormItemID(entryID)
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestParseItemIDsFromRequest(t *testing.T) {
formValues := url.Values{}
formValues.Add("i", "12345")
formValues.Add("i", "tag:google.com,2005:reader/item/00000000148b9369")
formValues.Add("i", "tag:google.com,2005:reader/item/2f2")
formValues.Add("i", "000000000000046f")
formValues.Add("i", "tag:google.com,2005:reader/item/272")
request := &http.Request{
Form: formValues,
}
result, err := parseItemIDsFromRequest(request)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var expected = []int64{12345, 344691561, 754, 1135, 626}
if !reflect.DeepEqual(result, expected) {
t.Errorf("expected %v, got %v", expected, result)
}
// Test with no item IDs
formValues = url.Values{}
request = &http.Request{
Form: formValues,
}
_, err = parseItemIDsFromRequest(request)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestParseItemID(t *testing.T) {
// Test with long form ID and hex ID
result, err := parseItemID("tag:google.com,2005:reader/item/0000000000000001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := int64(1)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with hexadecimal long form ID
result, err = parseItemID("0000000000000468")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(1128)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with short form ID
result, err = parseItemID("12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(12345)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with invalid long form ID
_, err = parseItemID("tag:google.com,2005:reader/item/000000000000000g")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with invalid short form ID
_, err = parseItemID("invalid_id")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with empty ID
_, err = parseItemID("")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
+1 -8
View File
@@ -173,18 +173,11 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// ParamItemIDs - name of the parameter with the item ids
ParamItemIDs = "i"
// ParamStreamID - name of the parameter containing the stream to be included
ParamStreamID = "s"
// ParamStreamExcludes - name of the parameter containing streams to be excluded
ParamStreamExcludes = "xt"
// ParamStreamFilters - name of the parameter containing streams to be included
ParamStreamFilters = "it"
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
ParamStreamMaxItems = "n"
// ParamStreamOrder - name of the parameter containing the sort criteria
ParamStreamOrder = "r"
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
ParamStreamStartTime = "ot"
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
ParamStreamStopTime = "nt"
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
ParamTagsRemove = "r"
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
ParamTagsAdd = "a"
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
ParamSubscribeAction = "ac"
// ParamTitle - name of the parameter for the title of the subscription
ParamTitle = "t"
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
ParamQuickAdd = "quickadd"
// ParamDestination - name of the parameter for the new name of a tag
ParamDestination = "dest"
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
ParamContinuation = "c"
// ParamStreamType - name of the parameter for unix timestamp
ParamTimestamp = "ts"
)
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
StreamPrefix = "user/-/state/com.google/"
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
UserStreamPrefix = "user/%d/state/com.google/"
// LabelPrefix is the prefix for a label stream
LabelPrefix = "user/-/label/"
// UserLabelPrefix is the user specific prefix prefix for a label stream
UserLabelPrefix = "user/%d/label/"
// FeedPrefix is the prefix for a feed stream
FeedPrefix = "feed/"
// Read is the suffix for read stream
Read = "read"
// Starred is the suffix for starred stream
Starred = "starred"
// ReadingList is the suffix for reading list stream
ReadingList = "reading-list"
// KeptUnread is the suffix for kept unread stream
KeptUnread = "kept-unread"
// Broadcast is the suffix for broadcast stream
Broadcast = "broadcast"
// BroadcastFriends is the suffix for broadcast friends stream
BroadcastFriends = "broadcast-friends"
// Like is the suffix for like stream
Like = "like"
)
+91
View File
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strings"
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
return result, nil
}
+7 -8
View File
@@ -68,14 +68,13 @@ type tagsResponse struct {
}
type streamContentItems struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Alternate []contentHREFType `json:"alternate"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
}
type contentItem struct {
+119
View File
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"strings"
)
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix)
switch id {
case Read:
return Stream{ReadStream, ""}, nil
case Starred:
return Stream{StarredStream, ""}, nil
case ReadingList:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
+10 -6
View File
@@ -16,6 +16,7 @@ type ContextKey int
// List of context keys.
const (
UserIDContextKey ContextKey = iota
UserNameContextKey
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
@@ -28,7 +29,6 @@ const (
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
PocketRequestTokenContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderToken
@@ -64,6 +64,15 @@ func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
value = "unknown"
}
return value
}
// UserTimezone returns the timezone used by the logged user.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
@@ -125,11 +134,6 @@ func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// PocketRequestToken returns the Pocket Request Token if any.
func PocketRequestToken(r *http.Request) string {
return getContextStringValue(r, PocketRequestTokenContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) int64 {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
-22
View File
@@ -390,28 +390,6 @@ func TestFlashErrorMessage(t *testing.T) {
}
}
func TestPocketRequestToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := PocketRequestToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
r = r.WithContext(ctx)
result = PocketRequestToken(r)
expected = "request token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
+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
}
+7 -6
View File
@@ -4,6 +4,7 @@
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"html"
"log/slog"
"net/http"
@@ -37,10 +38,10 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
@@ -61,10 +62,10 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
+6 -6
View File
@@ -58,7 +58,7 @@ func TestServerErrorResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("Some error"))
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -69,13 +69,13 @@ func TestServerErrorResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
@@ -91,7 +91,7 @@ func TestBadRequestResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some error"))
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -102,13 +102,13 @@ func TestBadRequestResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
+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)
+14
View File
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
// default-src 'none' disables all content sources
// form-action 'none' disables all form submissions
// sandbox enables a sandbox for the requested resource
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
+126 -99
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
@@ -30,36 +30,84 @@ import (
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) *http.Server {
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
listenAddresses := config.Opts.ListenAddr()
var httpServers []*http.Server
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
listenAddr := config.Opts.ListenAddr()
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
var sharedAutocertTLSConfig *tls.Config
if certDomain != "" {
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
sharedAutocertTLSConfig = &tls.Config{}
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.HTTPS = true
httpServers = append(httpServers, challengeServer)
}
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
startSystemdSocketServer(server)
case strings.HasPrefix(listenAddr, "/"):
startUnixSocketServer(server, listenAddr)
case certDomain != "":
config.Opts.HTTPS = true
startAutoCertTLSServer(server, certDomain, store)
case certFile != "" && keyFile != "":
config.Opts.HTTPS = true
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
default:
server.Addr = listenAddr
startHTTPServer(server)
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
shouldAddServer = false
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.HTTPS = true
default:
server.Addr = listenAddr
startHTTPServer(server)
}
if shouldAddServer {
httpServers = append(httpServers, server)
}
}
return server
return httpServers
}
func startSystemdSocketServer(server *http.Server) {
@@ -72,83 +120,47 @@ func startSystemdSocketServer(server *http.Server) {
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
os.Remove(socketFile)
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
go func(sock string) {
listener, err := net.Listen("unix", sock)
if err != nil {
printErrorAndExit(`Server failed to start: %v`, err)
}
defer listener.Close()
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
if err := os.Chmod(sock, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission: %v`, err)
}
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
go func() {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}(socketFile)
}()
}
func tlsConfig() *tls.Config {
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
// And https://wiki.mozilla.org/Security/Server_Side_TLS
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
}
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
server.Addr = ":https"
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
server.TLSConfig = tlsConfig()
server.TLSConfig.GetCertificate = certManager.GetCertificate
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
slog.String("domain", certDomain),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
server.TLSConfig = tlsConfig()
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
@@ -156,7 +168,7 @@ func startTLSServer(server *http.Server, certFile, keyFile string) {
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
@@ -167,49 +179,64 @@ func startHTTPServer(server *http.Server) {
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
router := mux.NewRouter()
// These routes do not take the base path into consideration and are always available at the root of the server.
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
var subrouter *mux.Router
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
} else {
subrouter = router.NewRoute().Subrouter()
}
if config.Opts.HasMaintenanceMode() {
router.Use(func(next http.Handler) http.Handler {
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
router.Use(middleware)
subrouter.Use(middleware)
fever.Serve(router, store)
googlereader.Serve(router, store)
api.Serve(router, store, pool)
ui.Serve(router, store, pool)
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
api.Serve(subrouter, store, pool)
ui.Serve(subrouter, store, pool)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
return
}
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
w.Write([]byte("OK"))
}).Name("healthcheck")
router.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
router.Handle("/metrics", promhttp.Handler()).Name("metrics")
router.Use(func(next http.Handler) http.Handler {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
+1 -1
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"context"
+1 -1
View File
@@ -69,7 +69,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+1 -1
View File
@@ -82,7 +82,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+73 -24
View File
@@ -6,13 +6,13 @@ package integration // import "miniflux.app/v2/internal/integration"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/karakeep"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linkwarden"
@@ -22,12 +22,13 @@ import (
"miniflux.app/v2/internal/integration/nunuxkeeper"
"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"
@@ -194,24 +195,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.PocketEnabled {
slog.Debug("Sending entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkAceEnabled {
slog.Debug("Sending entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
@@ -426,6 +409,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.KarakeepEnabled {
slog.Debug("Sending entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := karakeep.NewClient(userIntegrations.KarakeepAPIKey, userIntegrations.KarakeepURL)
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
@@ -473,35 +474,47 @@ 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),
)
}
}
if userIntegrations.NtfyEnabled && feed.NtfyEnabled {
ntfyTopic := feed.NtfyTopic
if ntfyTopic == "" {
ntfyTopic = userIntegrations.NtfyTopic
}
slog.Debug("Sending new entries to Ntfy",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
slog.String("topic", ntfyTopic),
)
client := ntfy.NewClient(
userIntegrations.NtfyURL,
userIntegrations.NtfyTopic,
ntfyTopic,
userIntegrations.NtfyAPIToken,
userIntegrations.NtfyUsername,
userIntegrations.NtfyPassword,
@@ -553,6 +566,42 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
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 {
for _, entry := range entries {
+81
View File
@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type errorResponse struct {
Code string `json:"code"`
Error string `json:"error"`
}
type saveURLPayload struct {
Type string `json:"type"`
URL string `json:"url"`
}
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
}
func NewClient(apiToken string, apiEndpoint string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
}
func (c *Client) SaveURL(entryURL string) error {
requestBody, err := json.Marshal(&saveURLPayload{
Type: "link",
URL: entryURL,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
resp, err := c.wrapped.Do(req)
if err != nil {
return fmt.Errorf("karakeep: unable to send request: %v", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse response: %s", err)
}
if resp.Header.Get("Content-Type") != "application/json" {
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
}
if resp.StatusCode != http.StatusCreated {
var errResponse errorResponse
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
}
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
}
return nil
}
+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)
}
-132
View File
@@ -1,132 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"miniflux.app/v2/internal/version"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
type Connector struct {
consumerKey string
}
// NewConnector returns a new Pocket Connector.
func NewConnector(consumerKey string) *Connector {
return &Connector{consumerKey}
}
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/request"
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result createTokenResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.Code == "" {
return "", errors.New("pocket: request token is empty")
}
return result.Code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result authorizeReponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.AccessToken == "" {
return "", errors.New("pocket: access token is empty")
}
return result.AccessToken, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
return fmt.Sprintf(
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
requestToken,
redirectURL,
)
}
type createTokenRequest struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
type createTokenResponse struct {
Code string `json:"code"`
}
type authorizeRequest struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
type authorizeReponse struct {
AccessToken string `json:"access_token"`
Username string `json:"username"`
}
-70
View File
@@ -1,70 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
consumerKey string
accessToken string
}
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing consumer key or access token")
}
apiEndpoint := "https://getpocket.com/v3/add"
requestBody, err := json.Marshal(&createItemRequest{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: entryTitle,
URL: entryURL,
})
if err != nil {
return fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("pocket: unable to create request: %v", err)
}
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("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
+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
}
+13 -1
View File
@@ -24,13 +24,16 @@ type BridgeMeta struct {
Name string `json:"name"`
}
func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
endpointURL, err := url.Parse(rssBridgeURL)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
}
values := endpointURL.Query()
if rssBridgeToken != "" {
values.Add("token", rssBridgeToken)
}
values.Add("action", "findfeed")
values.Add("format", "atom")
values.Add("url", websiteURL)
@@ -78,6 +81,15 @@ func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
slog.String("url", bridge.URL),
)
}
if rssBridgeToken != "" {
bridge.URL = bridge.URL + "&token=" + rssBridgeToken
slog.Debug("Appended token to RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
}
}
return bridgeResponse, nil
+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"`
}
@@ -5,8 +5,12 @@ package telegrambot // import "miniflux.app/v2/internal/integration/telegrambot"
import (
"fmt"
"log/slog"
"strconv"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
)
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
@@ -32,8 +36,16 @@ func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, to
if !disableButtons {
var markupRow []*InlineKeyboardButton
websiteURLButton := InlineKeyboardButton{Text: "Go to website", URL: feed.SiteURL}
markupRow = append(markupRow, &websiteURLButton)
baseURL := config.Opts.BaseURL()
entryPath := "/unread/entry/" + strconv.FormatInt(entry.ID, 10)
minifluxEntryURL, err := urllib.JoinBaseURLAndPath(baseURL, entryPath)
if err != nil {
slog.Error("Unable to create Miniflux entry URL", slog.Any("error", err))
} else {
minifluxEntryURLButton := InlineKeyboardButton{Text: "Go to Miniflux", URL: minifluxEntryURL}
markupRow = append(markupRow, &minifluxEntryURLButton)
}
articleURLButton := InlineKeyboardButton{Text: "Go to article", URL: entry.URL}
markupRow = append(markupRow, &articleURLButton)
+21 -19
View File
@@ -83,7 +83,7 @@ func TestMissingTranslations(t *testing.T) {
for key := range references {
if _, found := messages[key]; !found {
t.Fatalf(`Translation key %q not found in language %q`, key, language)
t.Errorf(`Translation key %q not found in language %q`, key, language)
}
}
}
@@ -91,24 +91,26 @@ func TestMissingTranslations(t *testing.T) {
func TestTranslationFilePluralForms(t *testing.T) {
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,
"de_DE": 2,
"el_EL": 2,
"en_US": 2,
"es_ES": 2,
"fi_FI": 2,
"fr_FR": 2,
"hi_IN": 2,
"id_ID": 1,
"it_IT": 2,
"ja_JP": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"pl_PL": 3,
"pt_BR": 2,
"ro_RO": 3,
"ru_RU": 3,
"tr_TR": 2,
"uk_UA": 3,
"zh_CN": 1,
"zh_TW": 1,
}
for language := range AvailableLanguages {
messages, err := loadTranslationFile(language)
+20 -18
View File
@@ -5,22 +5,24 @@ package locale // import "miniflux.app/v2/internal/locale"
// 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": "繁體中文",
"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",
"de_DE": "Deutsch",
"el_EL": "Ελληνικά",
"en_US": "English",
"es_ES": "Español",
"fi_FI": "Suomi",
"fr_FR": "Français",
"hi_IN": "हिन्दी",
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
"ja_JP": "日本語",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"pl_PL": "Polski",
"pt_BR": "Português Brasileiro",
"ro_RO": "Română",
"ru_RU": "Русский",
"tr_TR": "Türkçe",
"uk_UA": "Українська",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
}
+14 -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 {
@@ -71,6 +71,16 @@ var pluralForms = map[string](func(n int) int){
}
return 0
},
// nplurals=3; plural=(n==1 ? 0 : n==0 || (n%100 > 0 && n%100 < 20) ? 1 : 2);
"ro_RO": func(n int) int {
switch {
case n == 1:
return 0
case n == 0 || (n%100 > 0 && n%100 < 20):
return 1
}
return 2
},
"ru_RU": pluralFormRuSrUa,
// nplurals=2; plural=(n > 1);
"tr_TR": func(n int) int {
@@ -88,6 +98,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);
+9
View File
@@ -49,6 +49,11 @@ func TestPluralRules(t *testing.T) {
2: 1,
5: 1,
},
"ro_RO": {
1: 0,
2: 1,
5: 1,
},
"ru_RU": {
1: 0,
2: 1,
@@ -77,6 +82,10 @@ func TestPluralRules(t *testing.T) {
1: 0,
5: 0,
},
"nan_Latn_pehoeji": {
1: 0,
5: 0,
},
}
for rule, values := range scenarios {
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,599 @@
{
"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.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.mark_as_read": "Chù chòe tha̍k kè",
"entry.status.mark_as_unread": "Chù chòe ah-bōe tha̍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.tags.label": "Khan-á:",
"entry.tags.more_tags_label": [
"Kah %d 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_categories_sorting_order": "Lūi-pia̍t ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu 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_entry_order": "Siau-sit ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
"error.invalid_feed_proxy_url": "Proxy URL ū būn-tôe.",
"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.proxy_url_not_empty": "Proxy URL bōe-sái sī khang--ê.",
"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.block_filter_entry_rules": "Entry Blocking Rules",
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
"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": "Iōng tī su-hāu-khì siat-tēng ê proxy",
"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.keep_filter_entry_rules": "Entry Allow Rules",
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
"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.ntfy_topic": "Ntfy topic (soán thiⁿ)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Pó-chûn siau-sit kàu pushover.net",
"form.feed.label.pushover_default_priority": "Pushover ū-siat iu-sian sūn-sū",
"form.feed.label.pushover_high_priority": "Pushover koân iu-sian sūn-sū",
"form.feed.label.pushover_low_priority": "Pushover kē iu-sian sūn-sū",
"form.feed.label.pushover_max_priority": "Pushover siōng koân iu-sian sūn-sū",
"form.feed.label.pushover_min_priority": "Pushover siōng kē iu-sian sūn-sū",
"form.feed.label.pushover_priority": "Pushover siau-sit iu-sian sūn-sū",
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
"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.karakeep_activate": "Pó-chûn siau-sit kàu Karakeep",
"form.integration.karakeep_api_key": "Karakeep API só-sî",
"form.integration.karakeep_url": "Karakeep API thâu",
"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 (chhī-liāu nā bô siat-tēng, tiō iōng ī-siat-ti̍t)",
"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.pushover_activate": "Pó-chûn siau-sit kàu Pushover",
"form.integration.pushover_device": "Pushover ki-hì (soán thiⁿ)",
"form.integration.pushover_prefix": "Pushover URL tó͘-bí (soán thiⁿ)",
"form.integration.pushover_token": "Pushover application API só-sî",
"form.integration.pushover_user": "Pushover sú-iōng-lâng só-sî",
"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_token": "RSS-Bridge authentication token",
"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.always_open_external_links": "Chhiau-chhē bûn-chiong sī iōng gōa-pō͘ liân-kiat phah khui",
"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.open_external_links_in_new_tab": "Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\"_blank\")",
"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": "Iû-lâm-khì",
"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 sió UI",
"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.db_usage": "Database chhài-chhiú:",
"page.about.git_commit": "Git Commit:",
"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.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.categories_count": [
"%d ê 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.title": "Hun-hióng kè ê siau-sit",
"page.shared_entries_count": [
"Í-keng hun-hióng %d ê siau-sit"
],
"page.starred.title": "Siu-chông",
"page.starred_entry_count": [
"%d ê siu-chông ê siau-sit"
],
"page.total_entry_count": [
"Lóng-chóng %d ê siau-sit"
],
"page.unread.title": "Ah-bōe tha̍k",
"page.unread_entry_count": [
"%d ê siau-sit 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": "Thiaⁿ--khì chhòng-bûn",
"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
+635
View File
@@ -0,0 +1,635 @@
{
"action.cancel": "abandon",
"action.download": "Descărcare",
"action.edit": "Editare",
"action.home_screen": "Adaugă pe ecranul principal",
"action.import": "Importă",
"action.login": "Autentificare",
"action.or": "sau",
"action.remove": "Elimină",
"action.remove_feed": "Elimină acest flux",
"action.save": "Salvează",
"action.subscribe": "Abonează-te",
"action.update": "Actualizare",
"alert.account_linked": "Contul dvs. extern este atașat!",
"alert.account_unlinked": "Am decuplat contul dvs. extern!",
"alert.background_feed_refresh": "Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.",
"alert.feed_error": "Este o problemă cu acest flux",
"alert.no_bookmark": "Nu sunt înregistrări marcate.",
"alert.no_category": "Nu sunt categorii.",
"alert.no_category_entry": "Nu sunt înregistrări în această categorie.",
"alert.no_feed": "Nu aveți fluxuri.",
"alert.no_feed_entry": "Nu sunt înregistrări pentru acest flux.",
"alert.no_feed_in_category": "Nu sunt fluxuri pentru această categorie.",
"alert.no_history": "Nu există istoric în acest moment.",
"alert.no_search_result": "Nu există înregistrări pentru această căutare.",
"alert.no_shared_entry": "Nu sunt înregistrări partajate.",
"alert.no_tag_entry": "Nu sunt înregistrări pentru această etichetă.",
"alert.no_unread_entry": "Nu sunt intrări necitite.",
"alert.no_user": "Sunteți singurul utilizator.",
"alert.prefs_saved": "Preferințe salvate!",
"alert.too_many_feeds_refresh": [
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minut înainte de a reîncerca.",
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca.",
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca."
],
"confirm.loading": "În progres…",
"confirm.no": "nu",
"confirm.question": "Suneți sigur?",
"confirm.question.refresh": "Sunteți sigur că vreți să forțați reîmprospătarea?",
"confirm.yes": "da",
"enclosure_media_controls.seek": "Caută:",
"enclosure_media_controls.seek.title": "Caută %s secunde",
"enclosure_media_controls.speed": "Viteză:",
"enclosure_media_controls.speed.faster": "Mai rapid",
"enclosure_media_controls.speed.faster.title": "Mai rapid cu %sx",
"enclosure_media_controls.speed.reset": "Resetare",
"enclosure_media_controls.speed.reset.title": "Resetare viteză la 1x",
"enclosure_media_controls.speed.slower": "Mai încet",
"enclosure_media_controls.speed.slower.title": "Mai încet cu %sx",
"entry.bookmark.toast.off": "Fără stea",
"entry.bookmark.toast.on": "Cu stea",
"entry.bookmark.toggle.off": "Fără stea",
"entry.bookmark.toggle.on": "Stea",
"entry.comments.label": "Comentarii",
"entry.comments.title": "Vizualizare Comentarii",
"entry.estimated_reading_time": [
"%d minut de lectură",
"%d minute de lectură",
"%d minut de lectură"
],
"entry.external_link.label": "Legătură externă",
"entry.save.completed": "Gata!",
"entry.save.label": "Salvare",
"entry.save.title": "Salvez această înregistrare",
"entry.save.toast.completed": "Înregistrare salvată",
"entry.scraper.completed": "Gata!",
"entry.scraper.label": "Descărcare",
"entry.scraper.title": "Descarcă conținutul original",
"entry.share.label": "Partajare",
"entry.share.title": "Partajează această înregistrare",
"entry.shared_entry.label": "Partajare",
"entry.shared_entry.title": "Deschide legătura publică",
"entry.state.loading": "Încarc…",
"entry.state.saving": "Salvez…",
"entry.status.mark_as_read": "Marcați ca citit",
"entry.status.mark_as_unread": "Marcați ca necitit",
"entry.status.title": "Modifică starea intrării",
"entry.status.toast.read": "Marcat ca citit",
"entry.status.toast.unread": "Marcat ca necitit",
"entry.tags.label": "Etichete:",
"entry.tags.more_tags_label": [
"Afișează încă o etichetă",
"Afișează încă %d etichete",
"Afișează încă %d de etichete"
],
"entry.unshare.label": "Elimină partajarea",
"error.api_key_already_exists": "Această cheie API există deja.",
"error.bad_credentials": "Utilizator sau parolă invalide.",
"error.category_already_exists": "Această categorie există deja.",
"error.category_not_found": "Această categorie nu există sau nu aparține acestui utilizator.",
"error.database_error": "Eroare bază de date: %v.",
"error.different_passwords": "Parolele nu sunt identice.",
"error.duplicate_fever_username": "Este deja cineva cu același cont de Fever!",
"error.duplicate_googlereader_username": "Este deja cineva cu același nume de utilizator Google Reader!",
"error.duplicate_linked_account": "Este deja cineva asociat cu acest furnizor!",
"error.duplicated_feed": "Acest flux există deja.",
"error.empty_file": "Acest fișier este gol.",
"error.entries_per_page_invalid": "Numărul de înregistrări de pe pagină nu este valid.",
"error.feed_already_exists": "Acest flux există deja.",
"error.feed_category_not_found": "Această categorie nu există sau nu aparține utilizatorului.",
"error.feed_format_not_detected": "Nu pot detecta formatul fluxului: %v.",
"error.feed_invalid_blocklist_rule": "Blocul listei de reguli este invalid.",
"error.feed_invalid_keeplist_rule": "Lista de reguli keep este invalidă.",
"error.feed_mandatory_fields": "Adresa URL și categoria sunt obligatorii.",
"error.feed_not_found": "Acest flux nu există sau un aparține acestui utilizator.",
"error.feed_title_not_empty": "Titlul fluxului nu poate fi gol.",
"error.feed_url_not_empty": "Adresa URL a fluxului nu poate fi goală.",
"error.fields_mandatory": "Toate câmpurile sunt obligatorii.",
"error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_body_read": "Nu pot citi corpul HTTP: %v.",
"error.http_client_error": "Eroare client HTTP: %v.",
"error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
"error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
"error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",
"error.http_gateway_timeout": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_internal_server_error": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_not_authorized": "Accesul la acest site nu este autorizat. Poate fi din cauza parolei sau a userului greșite.",
"error.http_resource_not_found": "Resursa solicitată nu este găsită. Vă rog să verificați URL-ul.",
"error.http_response_too_large": "Răspunsul HTTP este prea mare. Puteți crește dimensiunea acestuia în setările globale (necesită repornirea server-ului).",
"error.http_service_unavailable": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_too_many_requests": "Miniflux a generat prea multe solicitări pe acest site web. Vă rog, încercați mai tîrziu sau modificați configurațiile aplicației.",
"error.http_unexpected_status_code": "Acest site web nu este disponibil momentan din cauza unei erori HTTP: %d. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.invalid_categories_sorting_order": "Ordinea de sortare a categoriilor nu este validă.",
"error.invalid_default_home_page": "Pagină de start invalidă!",
"error.invalid_display_mode": "Mod invalid de afișare în aplicația web.",
"error.invalid_entry_direction": "Direcție invalidă ăn intrare.",
"error.invalid_entry_order": "Direcție de sortare invalidă.",
"error.invalid_feed_proxy_url": "URL proxy invalid.",
"error.invalid_feed_url": "Adresa URL a fluxului este invalidă.",
"error.invalid_gesture_nav": "Gest de navigare invalid.",
"error.invalid_language": "Limbă invalidă.",
"error.invalid_site_url": "Adresa URL a site-ului este invalidă.",
"error.invalid_theme": "Temă invalidă.",
"error.invalid_timezone": "Dată/oră invalide.",
"error.network_operation": "Miniflux nu poate ajunge la acest site din cauza unei erori de rețea: %v.",
"error.network_timeout": "Acest site web este prea lent și conexiunea nu s-a realizat: %v",
"error.password_min_length": "Parola trebuie să aibă cel puțin 6 caractere.",
"error.proxy_url_not_empty": "URL-ul proxy nu poate fi gol.",
"error.settings_block_rule_fieldname_invalid": "Regulă de bloc invalidă: regulii #%d îi lipsește un nume valid de câmp (Opțiuni: %s)",
"error.settings_block_rule_invalid_regex": "Regulă de bloc invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_block_rule_regex_required": "Regulă de bloc invalidă: modelul regulii #%d's nu este furnizat",
"error.settings_block_rule_separator_required": "Regulă de bloc invalidă: modelul regulii #%d's trebuie separat de '='",
"error.settings_invalid_domain_list": "Lista domeniilor este invalidă. Vă rugăm să furnizați o listă de domenii separate prin spațiu.",
"error.settings_keep_rule_fieldname_invalid": "Regulă Keep invalidă: regulii #%d îi lipsește un nume valid (Opțiuni: %s)",
"error.settings_keep_rule_invalid_regex": "Regulă Keep invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_keep_rule_regex_required": "Regulă Keep invalidă: modelul regulii #%d nu este furnizat",
"error.settings_keep_rule_separator_required": "Regulă Keep invalidă: modelul regulii #%d's trebuie separat de'='",
"error.settings_mandatory_fields": "Numele utilizatorului, tema, limba și fusul orar sunt obligatorii.",
"error.settings_media_playback_rate_range": "Viteza de rulare nu este validă",
"error.settings_reading_speed_is_positive": "Vitezele de citire trebuie să fie numere întregi pozitive.",
"error.site_url_not_empty": "Adresa URL a site-ului nu poate fi goală.",
"error.subscription_not_found": "Nu se poate găsi nici un flux.",
"error.title_required": "Titlul este obligatoriu.",
"error.tls_error": "Eroare TLS: %q. Puteți dezactiva verificarea TLS în setările fluxurilor dacă doriți.",
"error.unable_to_create_api_key": "Nu pot crea această cheie API.",
"error.unable_to_create_category": "Nu se poate crea această categorie.",
"error.unable_to_create_user": "Nu se poate crea utilizatorul.",
"error.unable_to_detect_rssbridge": "Nu pot detecta fluxul când utilizez RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Nu pot procesa acest flux: %v.",
"error.unable_to_update_category": "Nu se poate actualiza această categorie.",
"error.unable_to_update_feed": "Nu se poate actualiza acest flux.",
"error.unable_to_update_user": "Nu se poate actualiza utilizatorul.",
"error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
"error.user_already_exists": "Acest utilizator există deja.",
"error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
"form.api_key.label.description": "Etichetă Cheie API",
"form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.category.label.title": "Titlu",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Servicii Terțe",
"form.feed.fieldset.network_settings": "Setări Rețea",
"form.feed.fieldset.rules": "Reguli",
"form.feed.label.allow_self_signed_certificates": "Permite certificatele auto-semnate sau invalide",
"form.feed.label.apprise_service_urls": "Lista de URL-uri ale serviciilor Apprise separate prin virgule",
"form.feed.label.block_filter_entry_rules": "Reguli de Blocare a Intrărilor",
"form.feed.label.blocklist_rules": "Filtre de Blocare Bazate pe Regex",
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Setare Cookie-uri",
"form.feed.label.crawler": "Aduce conținutul original",
"form.feed.label.description": "Descriere",
"form.feed.label.disable_http2": "Dezactivează HTTP/2 pentru a preveni amprentarea",
"form.feed.label.disabled": "Nu actualiza acest flux",
"form.feed.label.feed_password": "Parolă Flux",
"form.feed.label.feed_url": "Flux URL",
"form.feed.label.feed_username": "Nume user Flux",
"form.feed.label.fetch_via_proxy": "Utilizați proxy-ul configurat la nivelul aplicației",
"form.feed.label.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.feed.label.ignore_http_cache": "Ignoră cache HTTP",
"form.feed.label.keep_filter_entry_rules": "Reguli de Permitere a Intrărilor",
"form.feed.label.keeplist_rules": "Filtre de Păstrare Bazate pe Regex",
"form.feed.label.no_media_player": "Nu există player media (audio/video)",
"form.feed.label.ntfy_activate": "Împinge intrările la ntfy",
"form.feed.label.ntfy_default_priority": "Prioritate predefinită Ntfy",
"form.feed.label.ntfy_high_priority": "Prioritate ridicată Ntfy",
"form.feed.label.ntfy_low_priority": "Prioritate redusă Ntfy",
"form.feed.label.ntfy_max_priority": "Prioritate maximă Ntfy",
"form.feed.label.ntfy_min_priority": "Prioritate minimă Ntfy",
"form.feed.label.ntfy_priority": "Prioritate Ntfy",
"form.feed.label.ntfy_topic": "Subiect Ntfy (opțional)",
"form.feed.label.proxy_url": "URL Proxy",
"form.feed.label.pushover_activate": "Activează Pushover",
"form.feed.label.pushover_default_priority": "Prioritate implicită Pushover",
"form.feed.label.pushover_high_priority": "Prioritate ridicată Pushover",
"form.feed.label.pushover_low_priority": "Prioritate redusă Pushover",
"form.feed.label.pushover_max_priority": "Prioritate maximă Pushover",
"form.feed.label.pushover_min_priority": "Prioritate minimă Pushover",
"form.feed.label.pushover_priority": "Prioritate Pushover",
"form.feed.label.rewrite_rules": "Reguli de Rescriere a Conținutului",
"form.feed.label.scraper_rules": "Reguli de Eliminare",
"form.feed.label.site_url": "Adresă URL",
"form.feed.label.title": "Titlu",
"form.feed.label.urlrewrite_rules": "URL Reguli de Rescriere",
"form.feed.label.user_agent": "Suprascrie User Agent Predefinit",
"form.feed.label.webhook_url": "URL Webhook (pentru a primi notificări despre evenimentele de intrare)",
"form.import.label.file": "Fișier OPML",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Trimite înregistrările pe Apprise",
"form.integration.apprise_services_url": "URL-uri separate de virgulă cu servicii Apprise",
"form.integration.apprise_url": "URL API Apprise",
"form.integration.betula_activate": "Salvează înregistrările în Betula",
"form.integration.betula_token": "Token Betula",
"form.integration.betula_url": "Adresă server Betula",
"form.integration.cubox_activate": "Salvează intrările în Cubox",
"form.integration.cubox_api_link": "Link APi Cubox",
"form.integration.discord_activate": "Împinge intrările pe Discord",
"form.integration.discord_webhook_link": "Link Webhook Discord",
"form.integration.espial_activate": "Salvează intrările în Espial",
"form.integration.espial_api_key": "Cheie API Espial",
"form.integration.espial_endpoint": "Punct acces API Espial",
"form.integration.espial_tags": "Etichete Espial",
"form.integration.fever_activate": "Activează API Fever",
"form.integration.fever_endpoint": "Punct access API Fever:",
"form.integration.fever_password": "Parolă Fever",
"form.integration.fever_username": "Utilizator Fever",
"form.integration.googlereader_activate": "Activează API Google Reader",
"form.integration.googlereader_endpoint": "Punct acces API Google Reader:",
"form.integration.googlereader_password": "Parolă Google Reader",
"form.integration.googlereader_username": "Utilizator Google Reader",
"form.integration.instapaper_activate": "Salvează înregistrările pe Instapaper",
"form.integration.instapaper_password": "Parolă Instapaper",
"form.integration.instapaper_username": "Utilizator Instapaper",
"form.integration.karakeep_activate": "Salvare înregistrări în Karakeep",
"form.integration.karakeep_api_key": "Cheie API Karakeep",
"form.integration.karakeep_url": "Punct acces API Karakeep",
"form.integration.linkace_activate": "Salvează intrările în LinkAce",
"form.integration.linkace_api_key": "Cheie API LinkAce",
"form.integration.linkace_check_disabled": "Dezactivează verificarea link-urilor",
"form.integration.linkace_endpoint": "Endpoint API LinkAce",
"form.integration.linkace_is_private": "Marchează link-urile ca private",
"form.integration.linkace_tags": "Tag-uri LinkAce",
"form.integration.linkding_activate": "Salvează intrările în Linkding",
"form.integration.linkding_api_key": "Cheie API Linkding",
"form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
"form.integration.linkding_endpoint": "Endpoint API Linkding",
"form.integration.linkding_tags": "TAG-uri Linkding",
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint API Linkwarden",
"form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
"form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
"form.integration.matrix_bot_password": "Parola utilizatorului Matrix",
"form.integration.matrix_bot_url": "Server URL Matrix",
"form.integration.matrix_bot_user": "Utilizator Matrix",
"form.integration.notion_activate": "Salvează înregistrările în Notion",
"form.integration.notion_page_id": "ID Pagină Notion",
"form.integration.notion_token": "Token Secret Notion",
"form.integration.ntfy_activate": "Împinge intrările pe ntfy",
"form.integration.ntfy_api_token": "Token API Ntfy (opțional)",
"form.integration.ntfy_icon_url": "Icon URL Ntfy (opțional)",
"form.integration.ntfy_internal_links": "Utilizează legături interne la clic (opțional)",
"form.integration.ntfy_password": "Parolă Ntfy (opțional)",
"form.integration.ntfy_topic": "Topic Ntfy",
"form.integration.ntfy_url": "URL Ntfy (opțional, predefinit este ntfy.sh)",
"form.integration.ntfy_username": "Utilizator Ntfy (opțional)",
"form.integration.nunux_keeper_activate": "Salvează înregistrările în Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Cheie API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Punct de acces API Keeper",
"form.integration.omnivore_activate": "Salvare înregistrări în Omnivore",
"form.integration.omnivore_api_key": "Cheie API Omnivore",
"form.integration.omnivore_url": "Punct acces API Omnivore",
"form.integration.pinboard_activate": "Salvează intrările în Pinboard",
"form.integration.pinboard_bookmark": "Marchează bookmark ca necitit",
"form.integration.pinboard_tags": "Etichete Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pushover_activate": "Activează Pushover",
"form.integration.pushover_device": "Dispozitiv Pushover (opțional)",
"form.integration.pushover_prefix": "Prefix Pushover (opțional)",
"form.integration.pushover_token": "Token Pushover",
"form.integration.pushover_user": "Utilizator Pushover",
"form.integration.raindrop_activate": "Salvează intrările în Raindrop",
"form.integration.raindrop_collection_id": "ID Colecție",
"form.integration.raindrop_tags": "Tag-uri (separate de virgulă)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.readeck_activate": "Salvează intrările în readeck",
"form.integration.readeck_api_key": "Cheie API Readeck",
"form.integration.readeck_endpoint": "URL Readeck",
"form.integration.readeck_labels": "Etichete Readeck",
"form.integration.readeck_only_url": "Trimite numai URL (în loc de tot conținutul)",
"form.integration.readwise_activate": "Salvare înregistrări în Readwise Reader",
"form.integration.readwise_api_key": "Token Acces Readwise Reader",
"form.integration.readwise_api_key_link": "Obțineți Token-ul de Acess pe Readwise",
"form.integration.rssbridge_activate": "Verifică RSS-Bridge la adăugarea de abonamente",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "URL server RSS-Bridge",
"form.integration.shaarli_activate": "Salvează articolele în Shaarli",
"form.integration.shaarli_api_secret": "Secret API Shaarli",
"form.integration.shaarli_endpoint": "URL Shaarli",
"form.integration.shiori_activate": "Salvează articolele în Shiori",
"form.integration.shiori_endpoint": "Endpoint API Shiori",
"form.integration.shiori_password": "Parolă Shiori",
"form.integration.shiori_username": "Utilizator Shiori",
"form.integration.slack_activate": "Împinge intrările pe Slack",
"form.integration.slack_webhook_link": "Link Webhook Slack",
"form.integration.telegram_bot_activate": "Împingeți înregistrările noi pe chat-ul Telegram",
"form.integration.telegram_bot_disable_buttons": "Dezactivează butoanele",
"form.integration.telegram_bot_disable_notification": "Dezactivează notificările",
"form.integration.telegram_bot_disable_web_page_preview": "Dezactivează previzualizarea paginii web",
"form.integration.telegram_bot_token": "Token Bot",
"form.integration.telegram_chat_id": "ID Chat",
"form.integration.telegram_topic_id": "ID Topic",
"form.integration.wallabag_activate": "Salvează înregistrările în Wallabag",
"form.integration.wallabag_client_id": "ID Client Wallabag",
"form.integration.wallabag_client_secret": "Secret Client Wallabag",
"form.integration.wallabag_endpoint": "URL Wallabag",
"form.integration.wallabag_only_url": "Trimite numai URL-ul (fără conținut complet)",
"form.integration.wallabag_password": "Parolă Wallabag",
"form.integration.wallabag_username": "Utilizator Wallabag",
"form.integration.webhook_activate": "Activează Webhook",
"form.integration.webhook_secret": "Secret Webhook",
"form.integration.webhook_url": "URL Webhook",
"form.prefs.fieldset.application_settings": "Setări Aplicație",
"form.prefs.fieldset.authentication_settings": "Setări Autentificare",
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
"form.prefs.fieldset.reader_settings": "Setări Citire",
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Citește articolele deschizând linkurile externe",
"form.prefs.label.categories_sorting_order": "Sortare categorii",
"form.prefs.label.cjk_reading_speed": "Viteză de citire pentru Chineză, Coreană și Japoneză (caractere pe minut)",
"form.prefs.label.custom_css": "CSS personalizat",
"form.prefs.label.custom_js": "JavaScript personalizat",
"form.prefs.label.default_home_page": "Pagina pornire predefinită",
"form.prefs.label.default_reading_speed": "Viteză de citire pentru alte limbi (cuvinte pe minut)",
"form.prefs.label.display_mode": "Mod afișare Aplicație Web Progresivă (PWA)",
"form.prefs.label.entries_per_page": "Intrări pe pagină",
"form.prefs.label.entry_order": "Coloană de sortare",
"form.prefs.label.entry_sorting": "Sortare intrări",
"form.prefs.label.entry_swipe": "Activare glisare pentru ecranele tactile",
"form.prefs.label.external_font_hosts": "Fonturi externe gazdă",
"form.prefs.label.gesture_nav": "Gesturi pentru navigare între înregistrări",
"form.prefs.label.keyboard_shortcuts": "Activare scurtături tastatură",
"form.prefs.label.language": "Limbă",
"form.prefs.label.mark_read_manually": "Marchează manual intrările ca citite",
"form.prefs.label.mark_read_on_media_completion": "Marchează ca citit numai când redarea de conținut audio/video atinge 90%%",
"form.prefs.label.mark_read_on_view": "Marchează intrările ca citite la vizualizare",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut",
"form.prefs.label.media_playback_rate": "Viteza de rulare audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Deschide linkurile externe într-o filă nouă (adaugă target=\"_blank\" la linkuri)",
"form.prefs.label.show_reading_time": "Afișare timp estimat de citire pentru înregistrări",
"form.prefs.label.theme": "Temă",
"form.prefs.label.timezone": "Fus orar",
"form.prefs.select.alphabetical": "Alfabetic",
"form.prefs.select.browser": "Browser",
"form.prefs.select.created_time": "Dată creare înregistrare",
"form.prefs.select.fullscreen": "Ecran complet",
"form.prefs.select.minimal_ui": "Minim",
"form.prefs.select.none": "Nimic",
"form.prefs.select.older_first": "Intrările mai vechi la început",
"form.prefs.select.publish_time": "Data publicare înregistrare",
"form.prefs.select.recent_first": "Intrările mai noi la început",
"form.prefs.select.standalone": "Independent",
"form.prefs.select.swipe": "Glisare",
"form.prefs.select.tap": "Apăsare dublă",
"form.prefs.select.unread_count": "Contor necitite",
"form.submit.loading": "Încarc…",
"form.submit.saving": "Salvez…",
"form.user.label.admin": "Administrator",
"form.user.label.confirmation": "Confirmare Parolă",
"form.user.label.password": "Parolă",
"form.user.label.username": "Nume utilizator",
"menu.about": "Despre",
"menu.add_feed": "Adaugă flux",
"menu.add_user": "Adaugă utilizator",
"menu.api_keys": "Chei API",
"menu.categories": "Categorii",
"menu.create_api_key": "Crează o nouă cheie API",
"menu.create_category": "Crează o categorie",
"menu.edit_category": "Editare",
"menu.edit_feed": "Editare",
"menu.export": "Exportă",
"menu.feed_entries": "Intrări",
"menu.feeds": "Fluxuri",
"menu.flush_history": "Elimină istoricul",
"menu.history": "Istoric",
"menu.home_page": "Pagina principală",
"menu.import": "Importă",
"menu.integrations": "Integrări",
"menu.logout": "Deconectare",
"menu.mark_all_as_read": "Marchează tot ca citit",
"menu.mark_page_as_read": "Marchează această pagină ca citită",
"menu.preferences": "Preferințe",
"menu.refresh_all_feeds": "Reînnoiește toate fluxurile în fundal",
"menu.refresh_feed": "Reînnoire",
"menu.search": "Caută",
"menu.sessions": "Sesiuni",
"menu.settings": "Setări",
"menu.shared_entries": "Intrări partajate",
"menu.show_all_entries": "Afișează toate intrările",
"menu.show_only_starred_entries": "Afișează numai intrările marcate",
"menu.show_only_unread_entries": "Afișează numai intrările necitite",
"menu.starred": "Marcat",
"menu.title": "Meniu",
"menu.unread": "Necitit",
"menu.users": "Utilizatori",
"page.about.author": "Autor:",
"page.about.build_date": "Dată Build:",
"page.about.credits": "Credit",
"page.about.db_usage": "Utilizare Bază de Date",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Opțiuni globale de configurare",
"page.about.go_version": "Versiune Go:",
"page.about.license": "Licență:",
"page.about.postgres_version": "Versiune Postgres:",
"page.about.title": "Despre",
"page.about.version": "Versiune:",
"page.add_feed.choose_feed": "Alegeți un flux",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opțiuni Avansate",
"page.add_feed.no_category": "Nu există categorii. Trebuie să aveți măcar o categorie.",
"page.add_feed.submit": "Găsește un flux",
"page.add_feed.title": "Flux nou",
"page.api_keys.never_used": "Niciodată Utilizată",
"page.api_keys.table.actions": "Acțiuni",
"page.api_keys.table.created_at": "Dată Creare",
"page.api_keys.table.description": "Descriere",
"page.api_keys.table.last_used_at": "Utilizat ultima dată",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Chei API",
"page.categories.entries": "Intrări",
"page.categories.feed_count": [
"Este %d flux.",
"Sunt %d fluxuri.",
"Sunt %d fluxuri găsite."
],
"page.categories.feeds": "Fluxuri",
"page.categories.no_feed": "Nici un flux.",
"page.categories.title": "Categorii",
"page.categories_count": [
"%d categorie",
"%d categorii",
"%d categorie găsită"
],
"page.category_label": "Categorie: %s",
"page.edit_category.title": "Editare Categorie: %s",
"page.edit_feed.etag_header": "Antet ETag:",
"page.edit_feed.last_check": "Ultima verificare:",
"page.edit_feed.last_modified_header": "UltimaModificare antet:",
"page.edit_feed.last_parsing_error": "Ultima Eroare la Analiză",
"page.edit_feed.no_header": "Nimic",
"page.edit_feed.title": "Editare Flux: %s",
"page.edit_user.title": "Editare Utilizator: %s",
"page.entry.attachments": "Atașamente",
"page.feeds.error_count": [
"%d eroare",
"%d erori",
"%d erori găsite"
],
"page.feeds.last_check": "Ultima verificare:",
"page.feeds.next_check": "Următoarea verificare:",
"page.feeds.read_counter": "Numărul de intrări citite",
"page.feeds.title": "Fluxuri",
"page.history.title": "Istoric",
"page.import.title": "Import",
"page.integration.bookmarklet": "Marcaje",
"page.integration.bookmarklet.help": "Această legătură specială permite să vă abonați direct pe un site prin utilizarea unui marcaj în browser-ul web.",
"page.integration.bookmarklet.instructions": "Trageți legătura în favorite.",
"page.integration.bookmarklet.name": "Adaugă în Miniflux",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Punct de acces API",
"page.integration.miniflux_api_password": "Parolă",
"page.integration.miniflux_api_password_value": "Parola contului",
"page.integration.miniflux_api_username": "Utilizator",
"page.integrations.title": "Integrări",
"page.keyboard_shortcuts.close_modal": "Închide fereastra de dialog",
"page.keyboard_shortcuts.download_content": "Descarcă conținutul original",
"page.keyboard_shortcuts.go_to_bottom_item": "Du-te la ultimul obiect",
"page.keyboard_shortcuts.go_to_categories": "Du-te la categorii",
"page.keyboard_shortcuts.go_to_feed": "Du-te la flux",
"page.keyboard_shortcuts.go_to_feeds": "Du-te la fluxuri",
"page.keyboard_shortcuts.go_to_history": "Du-te la istoric",
"page.keyboard_shortcuts.go_to_next_item": "Du-te la obiectul următor",
"page.keyboard_shortcuts.go_to_next_page": "Du-te la pagina următoare",
"page.keyboard_shortcuts.go_to_previous_item": "Du-te la obiectul anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Du-te la pagina anterioară",
"page.keyboard_shortcuts.go_to_search": "Focusul pe formularul de căutare",
"page.keyboard_shortcuts.go_to_settings": "Du-te la setări",
"page.keyboard_shortcuts.go_to_starred": "Du-te la marcat",
"page.keyboard_shortcuts.go_to_top_item": "Du-te la primul obiect",
"page.keyboard_shortcuts.go_to_unread": "Du-te la necitit",
"page.keyboard_shortcuts.mark_page_as_read": "Marchează pagina curentă ca citită",
"page.keyboard_shortcuts.open_comments": "Deschide comentariile link-ului",
"page.keyboard_shortcuts.open_comments_same_window": "Deschide comentariile link-ului în tab-ul curent",
"page.keyboard_shortcuts.open_item": "Deschide obiectul selectat",
"page.keyboard_shortcuts.open_original": "Deschide link-ul original",
"page.keyboard_shortcuts.open_original_same_window": "Deschide link-ul original în tab-ul curent",
"page.keyboard_shortcuts.refresh_all_feeds": "Reîncarcă toate fluxurile în fundal",
"page.keyboard_shortcuts.remove_feed": "Elimină acest flux",
"page.keyboard_shortcuts.save_article": "Salvare înregistrare",
"page.keyboard_shortcuts.scroll_item_to_top": "Derulează obiectul la început",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Afișează scurtăturile tastaturii",
"page.keyboard_shortcuts.subtitle.actions": "Acțiuni",
"page.keyboard_shortcuts.subtitle.items": "Navigare Obiecte",
"page.keyboard_shortcuts.subtitle.pages": "Navigare Pagini",
"page.keyboard_shortcuts.subtitle.sections": "Navigare Secțiuni",
"page.keyboard_shortcuts.title": "Scurtături Tastatură",
"page.keyboard_shortcuts.toggle_bookmark_status": "Comută marcate",
"page.keyboard_shortcuts.toggle_entry_attachments": "Comută deschis/închis pe atașamentele înregistrării",
"page.keyboard_shortcuts.toggle_read_status_next": "Comută citit/necitit focus următor",
"page.keyboard_shortcuts.toggle_read_status_prev": "Comută citit/necitit, focus anterior",
"page.login.google_signin": "Conectare cu Google",
"page.login.oidc_signin": "Conectare cu %s",
"page.login.title": "Conectare",
"page.login.webauthn_login": "Conectare cu cheia de acces",
"page.login.webauthn_login.error": "Eroare la conectarea cu cheia de acces",
"page.login.webauthn_login.help": "Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).",
"page.new_api_key.title": "Cheie API Nouă",
"page.new_category.title": "Categorie Nouă",
"page.new_user.title": "Utilizator Nou",
"page.offline.message": "Sunteți offline",
"page.offline.refresh_page": "Încercați să reîmprospătați pagina",
"page.offline.title": "Mod Offline",
"page.read_entry_count": [
"%d înregistrare citită",
"%d înregistrări citite",
"%d înregistrări citite"
],
"page.search.title": "Rezultate Căutare",
"page.sessions.table.actions": "Acțiuni",
"page.sessions.table.current_session": "Sesiunea Curentă",
"page.sessions.table.date": "Dată",
"page.sessions.table.ip": "Adresă IP",
"page.sessions.table.user_agent": "Agent Utilizator",
"page.sessions.title": "Sesiuni",
"page.settings.link_google_account": "Atașează contul personal Google",
"page.settings.link_oidc_account": "Atașează contul meu %s",
"page.settings.title": "Setări",
"page.settings.unlink_google_account": "Decuplează contul personal Google",
"page.settings.unlink_oidc_account": "Decuplează contul meu %s",
"page.settings.webauthn.actions": "Acțiuni",
"page.settings.webauthn.added_on": "Adăugată în",
"page.settings.webauthn.delete": [
"Elimină %d cheie de acces",
"Elimină %d chei de acces",
"Elimină %d chei de acces"
],
"page.settings.webauthn.last_seen_on": "Utilizat ultima dată",
"page.settings.webauthn.passkey_name": "Nume cheie acces",
"page.settings.webauthn.passkeys": "Chei Acces",
"page.settings.webauthn.register": "Înregistrare cheie acces",
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
"page.shared_entries.title": "Înregistrări partajate",
"page.shared_entries_count": [
"%d înregistrare partajată",
"%d înregistrări partajate",
"%d înregistrări partajate"
],
"page.starred.title": "Marcate",
"page.starred_entry_count": [
"%d înregistrare marcată",
"%d Înregistrări marcate",
"%d Înregistrări marcate"
],
"page.total_entry_count": [
"%d intrare în total",
"%d intrări în total",
"%d intrări în total"
],
"page.unread.title": "Necitite",
"page.unread_entry_count": [
"%d înregistrare necitită",
"%d înregistrări necitite",
"%d înregistrări necitite"
],
"page.users.actions": "Acțiuni",
"page.users.admin.no": "Nu",
"page.users.admin.yes": "Da",
"page.users.is_admin": "Administrator",
"page.users.last_login": "Ultima Conectare",
"page.users.never_logged": "Niciodată",
"page.users.title": "Utilizatori",
"page.users.username": "Nume",
"page.webauthn_rename.title": "Redenumire Cheie Acces",
"pagination.first": "Prima",
"pagination.last": "Ultima",
"pagination.next": "Următor",
"pagination.previous": "Anterior",
"search.label": "Caută",
"search.placeholder": "Caută…",
"search.submit": "Caută",
"skip_to_content": "Sari la conținut",
"time_elapsed.days": [
"%d zi în urmă",
"%d zile în urmă",
"%d zile în urmă"
],
"time_elapsed.hours": [
"%d oră în urmă",
"%d ore în urmă",
"%d ore în urmă"
],
"time_elapsed.minutes": [
"%d minut în urmă",
"%d minute în urmă",
"%d minute în urmă"
],
"time_elapsed.months": [
"%d lună în urmă",
"%d luni în urmă",
"%d luni în urmă"
],
"time_elapsed.not_yet": "încă nu",
"time_elapsed.now": "chiar acum",
"time_elapsed.weeks": [
"%d săptămână în urmă",
"%d săptămâni în urmă",
"%d săptămâni în urmă"
],
"time_elapsed.years": [
"%d an în urmă",
"%d ani în urmă",
"%d ani în urmă"
],
"time_elapsed.yesterday": "ieri",
"tooltip.keyboard_shortcuts": "Scurtături Tastatură: %s",
"tooltip.logged_user": "Atentificat ca %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
+54 -54
View File
@@ -14,9 +14,9 @@ import (
func TestProxyFilterWithHttpDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -39,8 +39,8 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
func TestProxyFilterWithHttpsDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -63,7 +63,7 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
func TestProxyFilterWithHttpNever(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "none")
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
@@ -86,7 +86,7 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
func TestProxyFilterWithHttpsNever(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "none")
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
@@ -109,9 +109,9 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
func TestProxyFilterWithHttpAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -134,9 +134,9 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
func TestProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -159,9 +159,9 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -221,9 +221,9 @@ func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "audio")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "audio")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -246,9 +246,9 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "https://proxy-example/proxy")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
var err error
parser := config.NewParser()
@@ -271,9 +271,9 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "http://:8080example.com")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
var err error
parser := config.NewParser()
@@ -321,8 +321,8 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
func TestProxyFilterWithHttpInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "invalid")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -345,8 +345,8 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
func TestProxyFilterWithHttpsInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "invalid")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -369,9 +369,9 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
func TestProxyFilterWithSrcset(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -394,9 +394,9 @@ func TestProxyFilterWithSrcset(t *testing.T) {
func TestProxyFilterWithEmptySrcset(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -419,9 +419,9 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
func TestProxyFilterWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -444,9 +444,9 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "https")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "https")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -469,8 +469,8 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
func TestProxyWithImageDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -493,8 +493,8 @@ func TestProxyWithImageDataURL(t *testing.T) {
func TestProxyWithImageSourceDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -517,9 +517,9 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
func TestProxyFilterWithVideo(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "video")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -542,9 +542,9 @@ func TestProxyFilterWithVideo(t *testing.T) {
func TestProxyFilterVideoPoster(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -567,9 +567,9 @@ func TestProxyFilterVideoPoster(t *testing.T) {
func TestProxyFilterVideoPosterOnce(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image,video")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()

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