Compare commits

...

117 Commits

Author SHA1 Message Date
Frédéric Guillot c166f80b58 fix: block RFC 6598 shared address space as non-public
Treat 100.64.0.0/10 as non-public in urllib.IsNonPublicIP.

This closes a gap where RFC 6598 shared address space was not
classified as non-public, which could allow outbound requests to
CGNAT addresses through code paths that rely on this helper.
2026-03-14 17:15:27 -07:00
Frédéric Guillot 075127c56f chore: add go:fix inline to deprecated client.New
Add a go:fix inline directive to client.New so Go 1.26 can
automatically rewrite callers to NewClient().

This keeps the deprecated wrapper in place while making migration
easier for library users.
2026-03-14 16:35:52 -07:00
dependabot[bot] 34fcf0e54f build(deps): bump golang.org/x/net from 0.51.0 to 0.52.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 17:42:44 -07:00
dependabot[bot] 7f4a1094f4 build(deps): bump github.com/go-webauthn/webauthn from 0.16.0 to 0.16.1
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.16.0 to 0.16.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.0...v0.16.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 17:40:45 -07:00
jvoisin 7846ddb3d7 refactor(googlereader): minor code simplifications
- Hoist 4 similar error into a single global variable
- Hoist a bunch of fmt.Sprintf calls outside of loops
- Remove an else-return construct
- Use strconv.FormatInt instead of fmt.Sprintf
2026-03-11 18:34:35 -07:00
jvoisin 6ff4f5ac67 perf(fetcher): save 6 bytes per requests
While this change might seem petty, making sure that an http(s) request fits in
a single packet might have significant positive performance impact.
2026-03-11 18:22:27 -07:00
dependabot[bot] bf738d4b7f build(deps): bump golang.org/x/image from 0.36.0 to 0.37.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.36.0 to 0.37.0.
- [Commits](https://github.com/golang/image/compare/v0.36.0...v0.37.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 18:13:41 -07:00
dependabot[bot] 7242890d96 build(deps): bump golang.org/x/crypto from 0.48.0 to 0.49.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 18:09:46 -07:00
dependabot[bot] b624cfd165 build(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 19:02:31 -07:00
dependabot[bot] bce1697c95 build(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 19:02:03 -07:00
dependabot[bot] 14462c4b2e build(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 19:01:49 -07:00
dependabot[bot] 3ae595ea1c build(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 19:00:35 -07:00
dependabot[bot] 4d76de1722 build(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 18:52:30 -07:00
dependabot[bot] 171ed7d2f6 build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 18:51:34 -07:00
dependabot[bot] d9a97e268d build(deps): bump github.com/tdewolff/minify/v2 from 2.24.9 to 2.24.10
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.9 to 2.24.10.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.9...v2.24.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 17:27:48 -08:00
dependabot[bot] 365f2e6ee7 build(deps): bump github.com/go-webauthn/webauthn from 0.15.0 to 0.16.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.15.0...v0.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 17:27:23 -08:00
dependabot[bot] 210993e146 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 20:14:34 -08:00
Frédéric Guillot fe2bfb27cf fix(fetcher): avoid possible SSRF TOCTOU/DNS-rebinding in private network check
Move the private-network IP check from a pre-flight DNS lookup into
net.Dialer.Control, which runs after DNS resolution but before the TCP
connection is established. This ensures the validated IP is the one
actually connected to, closing the TOCTOU window that allowed DNS
rebinding attacks.

As a side effect, the check now also applies to redirect targets,
which the previous pre-flight approach did not cover.
2026-03-02 11:07:03 -08:00
Frédéric Guillot f9b756ecf8 feat: add SSRF protection for integration HTTP clients
Add a shared HTTP client factory that blocks connections to private
network addresses at connect time via a custom DialContext, preventing
SSRF and DNS-rebinding attacks.

A new INTEGRATION_ALLOW_PRIVATE_NETWORKS option (default: false)
controls this behavior. Integrations targeting fixed external services
(Telegram, Archive.org, Pinboard, Notion, Instapaper) skip the check.
2026-03-01 21:56:41 -08:00
Frédéric Guillot e8b2785329 feat(processor): apply entry blocking both before and after scraping
Apply entry filters in two phases:
- Before scraping, to skip unnecessary requests.
- After scraping (when crawler runs), so rules can match fetched/original content.
2026-03-01 21:14:11 -08:00
Frédéric Guillot df7fc1e853 fix(integration): fix bugs, naming, and inconsistencies across sub-packages
Bugs fixed:
- pushover: inverted error-check logic (err != nil → err == nil)
- nunuxkeeper: wrong error prefix ("notion:" → "nunux-keeper:")
- wallabag: typo "wallbag" in five error messages
- omnivore: potential panic on empty errors array
- rssbridge: off-by-one status check (> 400 → >= 400)
- archiveorg: slog called with printf-style %v format verb
- integration: redundant TelegramBotEnabled check (dead code)
- integration: webhook failure logged at Debug instead of Warn

Non-idiomatic Go fixed:
- archiveorg: replace http.Get() with proper http.Client, add
  User-Agent header, remove unnecessary goroutine, return error
- apprise/discord/slack: use defer for response.Body.Close()
- omnivore: return concrete struct instead of interface from NewClient
- pushover: rename New() to NewClient() for consistency
- wallabag/ntfy: use named fields in struct literal initialization
- shiori: remove unnecessary named return values
- instapaper/pinboard/betula: remove Content-Type on bodyless requests
- rssbridge: add missing User-Agent header

Naming and style:
- Rename Url→URL, ClientRequestId→ClientRequestID in struct fields
  (espial, linkace, linkding, readeck, pinboard, omnivore)
- Rename SaveUrl→SaveURL method (omnivore)
- Unexport internal-only types (pushover, omnivore)
- Fix variable typo successReponse→successResponse (omnivore)
- Normalize error prefixes to lowercase "pkg:" style (pushover, rssbridge)
- Fix grammar "Rewrited"→"Rewrote" (rssbridge)
2026-03-01 20:27:30 -08:00
Frédéric Guillot 26824211aa feat: add FETCHER_ALLOW_PRIVATE_NETWORKS option
Block outbound requests to private networks made by the fetcher
by default. The restriction now applies to all outgoing requests
performed by the fetcher.

Previous PR #3947 intentionally enforced this restriction only
for the media proxy and icon fetching, considering the
self-hosted nature of Miniflux.
2026-02-28 21:12:15 -08:00
fguillot 0e13773d25 feat(locale): add Arabic (ar_SA) translation
Supersede PR #4024
2026-02-27 21:45:27 -08:00
ghose 8d01ca3637 feat(locale): add Galician (gl_ES) translation 2026-02-27 21:24:27 -08:00
Frédéric Guillot 8b84a86f0c feat: add Galician plural 2026-02-27 21:20:30 -08:00
dependabot[bot] be49cba34b build(deps): bump golang.org/x/net from 0.50.0 to 0.51.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/net/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 16:06:56 -08:00
jvoisin d7e049f187 ci(debian): don't build debian packages bi-weekly on forks 2026-02-23 16:42:01 -08:00
dependabot[bot] 4d7e8b2796 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.8 to 2.24.9
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.8 to 2.24.9.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.8...v2.24.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 16:36:59 -08:00
Frédéric Guillot 7f7f23d33d chore: upgrade to Go 1.26 2026-02-17 20:04:48 -08:00
Frédéric Guillot 01888d8e32 fix: run go fix
See https://go.dev/blog/gofix
2026-02-17 19:52:29 -08:00
jvoisin 223d29e5d9 refactor(icon): remove two loops
1. If the websiteURL is equal to the RootURL, there is no need to check them
   both.
2. Group the icon-looking-queries into a single one.
2026-02-17 19:39:05 -08:00
jvoisin d58cb5b72c refactor(icon): remove a useless function 2026-02-17 16:50:59 -08:00
jvoisin 313f23fd65 refactor(handler): factorize duplicate code into a function 2026-02-17 16:49:02 -08:00
jvoisin 54df471d0e refactor(reader): clarify a ScheduleNextCheck call
Right after `originalFeed.CheckedNow()`, `originalFeed.ScheduleNextCheck` is
called, with its second argument being `refreshdelay`, a time.Duration set to its implicit
default value. The variable `refreshdelay` is never reassigned, until much
deeper in the function. This commit replaces it with `time.Duration(0)`, to
make it explicit that no special delay is expected, and declares it later in a
restricted scope.
2026-02-17 16:44:52 -08:00
Julien Voisin 72784c4943 perf(finder): don't parse the candidate page twice
Instead of parsing the HTML once in findSubscriptionsFromWebPage and another
time in findCanonicalURL, do it once in a `parseHTMLDocument` function.
This was also the opportunity to restrict the `link[rel='canonical' i`
query to `head link[rel='canonical' i`.
2026-02-17 15:49:01 -08:00
jvoisin 7fa38aafe7 perf(urlcleaner): misc performance improvements
- Don't clean an url without any parameters
- Don't recompute the Hostname for parsedFeedURL and parsedSiteURL in
  a loop
- Don't keep processing a parameter once it has been detected as a tracking
  one.
2026-02-15 19:23:29 -08:00
Frédéric Guillot fb7f16ecf2 test(encoding): add KOI8-R encoding tests with a sample XML feed 2026-02-15 19:14:07 -08:00
Frédéric Guillot 8898c42f24 test(encoding): add more tests regarding CharsetReader 2026-02-15 14:10:50 -08:00
Matthaiks 0054314355 feat(locale): update Polish translation 2026-02-14 18:11:49 -08:00
jvoisin 28441d1e27 perf(readability): small performance improvements
- Don't create a string by concatenation on the heap to then pass it to a
  strings.Builder, pass everything, one by one, to it instead.
- Lower the complexity of shouldRemoveCandidate from quadratic to linear.
2026-02-14 17:16:53 -08:00
jvoisin 8d19529948 perf(parser): don't process the whole page to detect its format
There is no need to process the whole page to guess its format: if we can't
find a format indicator in the first 50 xml tokens, odds are that we won't find
it at all.

This should save some time when trying to find a feed, as this function is
called a handful of times on various pages.
2026-02-13 16:35:50 -08:00
jvoisin 6838f3a6e9 perf(sanitizer): use WriteByte for single characters instead of WriteString 2026-02-13 16:30:24 -08:00
jvoisin 8483f06595 perf(sanitizer): refactor hasRequiredAttributes
Instead of operating on a slices that is built/garbage-collected on every HTML
tag, use a struct keeping track of the mandatory attributes. This commit also
replaces two `continue` with `return`, as there is no point to continue
analysing the tag if the conditions surrounding the continue aren't met.
2026-02-13 16:30:24 -08:00
jvoisin da15700205 perf(sanitizer): inline getExtraAttributes
This saves allocating a temporary slice only to have it spread.
2026-02-13 16:30:24 -08:00
jvoisin cd6236b1cd perf(sanitize): Use strings.Builder instead of manually concatenating 2026-02-13 16:30:24 -08:00
Serpicroon 7b07b8256b feat(feed): add ignore_entry_updates option to feeds
It introduces a new configuration option `ignore_entry_updates` for feeds, allowing users to skip updating existing entries during scheduled polling.
This is useful when external services (e.g., AI summarizers) modify entry content and users want to preserve those modifications across feed syncs.
2026-02-13 16:25:15 -08:00
Frédéric Guillot d3a1e7dbec ci: update Debian packager Docker image to Trixie 2026-02-12 19:59:43 -08:00
Frédéric Guillot 7edf42fe18 fix: run go fmt with Go 1.26 2026-02-12 19:22:47 -08:00
dependabot[bot] 5513827cdd build(deps): bump github.com/lib/pq from 1.11.1 to 1.11.2
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.11.1 to 1.11.2.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.11.1...v1.11.2)

---
updated-dependencies:
- dependency-name: github.com/lib/pq
  dependency-version: 1.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-10 18:32:53 -08:00
dependabot[bot] 052ad27e38 build(deps): bump golang.org/x/net from 0.49.0 to 0.50.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:40:37 -08:00
dependabot[bot] 96c45874f5 build(deps): bump golang.org/x/crypto from 0.47.0 to 0.48.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:28:42 -08:00
dependabot[bot] 3e022fb86e build(deps): bump golang.org/x/image from 0.35.0 to 0.36.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/image/compare/v0.35.0...v0.36.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:21:08 -08:00
dependabot[bot] aa814491f6 build(deps): bump golang.org/x/oauth2 from 0.34.0 to 0.35.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:08:12 -08:00
dependabot[bot] 90c2199f0f build(deps): bump golang.org/x/term from 0.39.0 to 0.40.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/term/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:04:01 -08:00
Emiel Wiedijk d40e523d25 fix: 'v' shortcut uses main browser in Safari PWA
When using the 'v' shortcut in a Safari PWA on macOS, Miniflux opened
the link in something similar to an 'in-app browser' with no URL bar and
a separate window for each link. When clicking the link with the mouse,
a "normal" browser tab is opened instead.

It looks like target="_blank" on the <a> element implies the "noopener"
attribute, which causes Safari to use the main browser. For window.open,
"noopener" is not implied. This commit adds the "noreferrer" argument to
the window.open call (which implies the "noopener" option) rather than
changing the window.opener attribute manually. This option causes Safari
to use the main browser with the 'v' shortcut.
2026-02-09 16:09:09 -08:00
Frédéric Guillot 46e77eccb0 fix(request): change FindRemoteIP to fallback to 127.0.0.1 2026-02-08 19:39:26 -08:00
eyjhb f19fc2ff53 feat: add validation for TRUSTED_REVERSE_PROXY_NETWORKS config
Currently if the IP is not in CIDR notation it will just silently
fail, which can be very confusing. This commit changes that, as well
as adds a test.
2026-02-08 14:19:37 -08:00
jvoisin 0201c80db1 refactor(subscription): improve wp-json skip
No need for an extra-condition, we can simply express the constrain in the
query instead.
2026-02-08 14:00:11 -08:00
Frédéric Guillot 0022d0646b feat(subscription): ignore Wordpress API endpoint when discovering JSON feed 2026-02-06 16:56:28 -08:00
jvoisin 253119ed5f refactor(http): minor code cleanup
- Fix a grammar issue in the logs
- Factorize and name two checks
- Replace a variable with a `continue`
2026-02-06 16:31:58 -08:00
jvoisin 2619eba43c security(http): don't expose miniflux' version on an unauthenticated endpoint
There is no reason to expose miniflux' version to the internet.
2026-02-06 16:28:23 -08:00
Frédéric Guillot 31f4f366a9 fix(commit-checker): add security to conventional commit pattern 2026-02-06 16:23:59 -08:00
jvoisin fc9cb9e5d5 refactor(googlereader): unexport two symbols 2026-02-06 16:18:02 -08:00
jvoisin 9b859e7f3f refactor(storage): unexport a bunch of symbols 2026-02-06 16:17:18 -08:00
Julien Voisin 6f3c92db74 perf(subscription): don't read the body twice
Since FindSubscriptions is reading the Body in a []byte, there is no need to
wrap it into a Reader for NewCharsetReader to ReadAll into another buffer: we
can simply pass the []byte instead. For some large webpages, this might shave
off two allocation-deallocation of a handful of megabytes when looking for
subscriptions.
2026-02-06 16:15:51 -08:00
jvoisin 0dcc0a852b refactor(sanitizer): improve a bit sanitizeAttributes
- Sort blockedResourceURLSubstrings
- Add x.com to the list of blocked links
- Move isBlockedResource above ResolveToAbsoluteURLWithParsedBaseURL, to
  avoid having to properly parse blocked urls.
2026-02-05 21:48:21 -08:00
jvoisin 7b65255757 refactor(sanitizer): handle deeply nested recursion 2026-02-05 21:47:17 -08:00
jvoisin 6a9d7894d1 refactor(sanitizer): improve how attributes are sanitized
Move hasRequiredAttributes above getExtraAttributes, as extra attributes,
as their name implies, aren't required. This allows to return a []string
instead of a ([]string, []string), as well as simplifying getExtraAttributes
of course. Note that this resulted in an ordering change of <iframe> attributes
so the testsuite had to be updated.

This improves the performances of SanitizeHTML by a bit less than 10% on local
benchmarks.
2026-02-04 19:34:01 -08:00
jvoisin f3d0e0378b refactor(workflows): don't run useless on forks job forks
There is no need to build binaries and docker images, as well as mirroring to
codeberg, on fork repositories.
2026-02-02 15:48:36 -08:00
Frédéric Guillot 4d27f66ff9 refactor(sanitizer): reorder non-public functions alphabetically 2026-01-30 17:38:51 -08:00
Frédéric Guillot 5a8431b144 refactor(sanitizer): enforce isBlockedResource() on srcset URLs 2026-01-30 17:27:05 -08:00
Frédéric Guillot 9e760e14cd test(sanitizer): rewrite TestSelfClosingTags 2026-01-30 17:05:18 -08:00
Frédéric Guillot 879e40670c test: refactor TestImg and TestURIScheme 2026-01-30 16:57:39 -08:00
jvoisin bf24fd1ec9 perf(sanitizer): don't do a map lookup for every attribute of a tag
There is no need to do a lookup in allowedHTMLTagsAndAttributes in the loop,
as the tag never changes in this function. This is a minor optimization,
but since sanitizeAttributes is in the hot path of SanitizeHTML, it should be
worthwhile.
2026-01-30 16:40:57 -08:00
jvoisin b57ebac757 refactor(sanitizer): remove a useless case 2026-01-30 16:39:35 -08:00
Frédéric Guillot acfb7dc213 test: refactor TestAttrLowerCase 2026-01-30 16:39:23 -08:00
jvoisin 62f316c7cf refactor(sanitizer): html attributes keys are always lowercase 2026-01-30 16:33:21 -08:00
Frédéric Guillot 89638b448a refactor(sanitizer): remove workaround that strip large img width attribute
The browser will do the right thing by default even if the image width
is larger than Miniflux's layout.
2026-01-29 21:24:15 -08:00
jvoisin d8181b1f65 refactor(sanitizer): use a parser instead of a tokenizer
As stated in [golang.org/x/net/html's documentation](https://pkg.go.dev/golang.org/x/net):

> If your use case requires semantically well-formed HTML documents, as defined
by the WHATWG specification, the parser should be used rather than the
tokenizer.

The sanitizer is modifying the HTML document, and I'm pretty sure we don't want
to implement a WHATWG-compliant parser ourself, as there are _so many_
edge cases everywhere. Picking the parser instead of the tokenizer ensures that
miniflux has the same view of the DOM than web browsers, removing the risk of
disparities, and thus injection-related security issues. Another benefit of
this commit is to make the code way more readable/simple.

The only changes made to the testsuites are:

- Removing the trailing `/` on self-contained tags, as the spec doesn't require
  them. Adding them systematically could also have been done, but as the
  testsuite isn't consistent in this regard, it would have significantly
  increased the size of the commit to normalize it.
- Some weird things that were wrong in the first place, like how
  `<td rowspan="<b>injection</b>">text</td>` is interpreted by the browser as
  `text`, and not as `<td rowspan="&lt;b&gt;test&lt;/b&gt;">test</td>`.

Care has been taken to re-use as much code from the tokenizer-based sanitizer
as possible.
2026-01-29 20:36:09 -08:00
Bùi Minh Đức ce6cd0b2b6 fix(rewrite): update rewrite rules for vnexpress.net 2026-01-29 20:09:46 -08:00
Bùi Minh Đức ba05c96d88 feat(rewrite): add rewrite rules for vnexpress.net
Add lazy loading images, remove embedded related news articles, surveys, and fix video display
2026-01-29 20:09:46 -08:00
Bùi Minh Đức 58674b9277 feat(scraper): add scraper rule for vnexpress.net 2026-01-29 20:09:46 -08:00
dependabot[bot] 6b1f208209 build(deps): bump github.com/lib/pq from 1.10.9 to 1.11.1
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.10.9 to 1.11.1.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.10.9...v1.11.1)

---
updated-dependencies:
- dependency-name: github.com/lib/pq
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-29 19:29:49 -08:00
Julien Voisin 7c41128d88 refactor(sanitizer): use uint instead of int when possible
This helps with bound-check elimination, and makes the code a bit more clear.
2026-01-28 19:46:59 -08:00
Frédéric Guillot 3aea68e725 refactor(sanitizer): rewrite srcset parser according to HTML specs
The new implementation follows the WebKit HTMLSrcsetParser approach. It
correctly handles various edge cases that the previous string-splitting
method could not parse.
2026-01-27 20:06:45 -08:00
jvoisin a5fb91e3e8 refactor(sanitizer): rename isValidTag to isAllowedTag 2026-01-26 20:21:08 -08:00
jvoisin 8357e6decf refactor(sanitizer): don't process tags when len(blockedStack) != 0
Then there are things in blockedStack, it means that we're currently iterating
on children of a blocked tag, so there is no need to actually process them.
2026-01-26 20:21:08 -08:00
Bùi Minh Đức ad4ca71f68 feat(scraper): add scraper rule for bleepingcomputer.com 2026-01-26 15:45:28 -08:00
Bùi Minh Đức 660c79bbdd feat(rewrite): add rewrite rules for bleepingcomputer.com
Remove embedded in-article advertisements, links to other articles, and add lazy loading images
2026-01-26 15:45:28 -08:00
Frédéric Guillot 2fa8995a35 fix: avoid possible deadlock when cleaning removed entries
- Lock delete targets in order with SKIP LOCKED before deleting removed entries
- Have the removed-entry scrubber skip locked rows to prevent blocking
2026-01-19 12:04:57 -08:00
Frédéric Guillot ffed2b4fa1 fix(storage): do not keep any old enclosures if there is none in the updated entry 2026-01-19 11:24:54 -08:00
Frédéric Guillot 9fc69fd2e0 refactor(storage): update IconByFeedID to handle sql.ErrNoRows 2026-01-19 10:47:18 -08:00
Frédéric Guillot cddd2eb168 feat(jsonfeed): include external_url in JSON entry hash fallback 2026-01-18 17:41:47 -08:00
Frédéric Guillot 33b780d9c0 fix(jsonfeed): stop the title fallback loop at the first non-empty value 2026-01-18 17:29:43 -08:00
Frédéric Guillot 5d8540b324 fix(jsonfeed): avoid panic when parsing null JSON feed 2026-01-18 17:16:15 -08:00
Frédéric Guillot a2a5244387 feat(jsonfeed): support malformed feeds with author object in authors array 2026-01-18 17:01:55 -08:00
Frédéric Guillot 3a232d0c8d test(request): add 100% unit test coverage 2026-01-16 17:23:20 -08:00
Frédéric Guillot 5a8df8abf0 fix(fetcher): ensure response body is closed even on client error 2026-01-16 17:02:32 -08:00
Julien Voisin a4883ca11e refactor(fetcher): use modern string functions and improve error messages 2026-01-16 15:35:44 -08:00
Frédéric Guillot 4cd66aef5f docs(client): improve Godoc comments for exported functions 2026-01-16 14:31:38 -08:00
Frédéric Guillot 0ab3bb5efb refactor(api): replace inlined map and structs with named payload structs 2026-01-16 14:20:37 -08:00
Frédéric Guillot 71c551ffe0 test(timezone): add test for AvailableTimezones() 2026-01-16 13:59:09 -08:00
Frédéric Guillot bb05b25530 refactor(urllib): replace AbsoluteURL and GetAbsoluteURL 2026-01-16 13:50:54 -08:00
Frédéric Guillot 7b7fd48e15 test(validator): add more unit tests for the validation functions 2026-01-16 11:43:03 -08:00
Frédéric Guillot fb0fcfb266 fix(js): use arrow functions for touchcancel event to maintain context 2026-01-15 21:33:22 -08:00
Frédéric Guillot 39840bb0d2 refactor(js): avoid returning values from forEach callbacks to prevent ignored results 2026-01-15 21:20:00 -08:00
Frédéric Guillot 79a3369699 refactor(js): use template literals for string concatenation to improve readability 2026-01-15 21:01:48 -08:00
Frédéric Guillot 696b98bb16 refactor(js): store keyboard shortcuts in a Map instead of plain object 2026-01-15 20:44:29 -08:00
Márton Salomváry 85fa69ba38 fix: unbreak cmd/ctrl/shift click on main nav
Most browsers allow opening links in a new window or new
tab by holding down a modifier key like shift, command,
control (depending on the OS and browser) while clicking a link.

Unconditional event.preventDefault() on click events breaks this functionality.
2026-01-15 19:38:56 -08:00
jvoisin c5a9111d58 refactor(timezone): improve internal timezones handling
- Add a new IsValid method to avoid having to materialize a map just to check
  if a given timezone is in it.
- Use an iterator instead of materializing a map every time the full list of
  timezones is required.
- Use a slice in the template instead of a map to iterate over all timezones.
2026-01-12 20:40:10 -08:00
dependabot[bot] a46afe6979 build(deps): bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:34:56 -08:00
dependabot[bot] 2f67a68e75 build(deps): bump golang.org/x/crypto from 0.46.0 to 0.47.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.46.0 to 0.47.0.
- [Commits](https://github.com/golang/crypto/compare/v0.46.0...v0.47.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:21:01 -08:00
dependabot[bot] 83ca211cfb build(deps): bump golang.org/x/image from 0.34.0 to 0.35.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/image/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:10:53 -08:00
dependabot[bot] 55aa15dd52 build(deps): bump golang.org/x/term from 0.38.0 to 0.39.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/term/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 17:37:45 -08:00
Frédéric Guillot e54e6c097c feat(config)!: deprecate FILTER_ENTRY_MAX_AGE_DAYS config option
This option can be replaced with a filter rule `max-age:<duration>` instead.

Global environment variables should be reserved for the Miniflux process
configuration that are not meant to be modified by end users.
2026-01-08 20:28:01 -08:00
Frédéric Guillot 56a11d1d45 chore: remove prealloc linter 2026-01-07 20:24:43 -08:00
lclee3390 df28028b24 feat(ui): add filter to search results by unread status 2026-01-07 20:13:56 -08:00
188 changed files with 5877 additions and 2504 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ on:
jobs:
build:
name: Build
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -23,7 +24,7 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: binaries
path: miniflux-*
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
mirror:
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
+9 -8
View File
@@ -13,7 +13,8 @@ on:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
jobs:
test-packages:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux' )
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -21,9 +22,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -42,9 +43,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -53,7 +54,7 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.deb"
@@ -68,9 +69,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
+10 -9
View File
@@ -12,6 +12,7 @@ on:
jobs:
docker-images:
name: Docker Images
if: github.repository_owner == 'miniflux'
permissions:
packages: write
runs-on: ubuntu-latest
@@ -23,7 +24,7 @@ jobs:
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -36,7 +37,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -50,21 +51,21 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -72,14 +73,14 @@ jobs:
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
@@ -89,7 +90,7 @@ jobs:
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.rpm"
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Match
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
-1
View File
@@ -10,7 +10,6 @@ linters:
- loggercheck
- misspell
- perfsprint
- prealloc
- sqlclosecheck
- staticcheck
- whitespace
+2
View File
@@ -113,6 +113,8 @@ integration-test:
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
+1 -1
View File
@@ -3,7 +3,7 @@ Miniflux API Client
[![PkgGoDev](https://pkg.go.dev/badge/miniflux.app/v2/client)](https://pkg.go.dev/miniflux.app/v2/client)
Client library for Miniflux REST API.
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
Installation
------------
+16 -14
View File
@@ -22,6 +22,8 @@ type Client struct {
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
//
//go:fix inline
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
}
@@ -334,14 +336,14 @@ func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
// IntegrationsStatus fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatus() (bool, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IntegrationsStatusContext(ctx)
}
// IntegrationsStatusContext fetches the integrations status for the logged user.
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
body, err := c.request.Get(ctx, "/v1/integrations/status")
if err != nil {
@@ -360,7 +362,7 @@ func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
return response.HasIntegrations, nil
}
// Discover try to find subscriptions from a website.
// Discover tries to find subscriptions on a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -383,14 +385,14 @@ func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions
return subscriptions, nil
}
// Categories gets the list of categories.
// Categories retrieves the list of categories.
func (c *Client) Categories() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesContext(ctx)
}
// CategoriesContext gets the list of categories.
// CategoriesContext retrieves the list of categories.
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories")
if err != nil {
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
Title: new(title),
})
if err != nil {
return nil, err
@@ -537,14 +539,14 @@ func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64
return err
}
// CategoryFeeds gets feeds of a category.
// CategoryFeeds returns all feeds for a category.
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryFeedsContext(ctx, categoryID)
}
// CategoryFeedsContext gets feeds of a category.
// CategoryFeedsContext returns all feeds for a category.
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
if err != nil {
@@ -608,14 +610,14 @@ func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
return feeds, nil
}
// Export creates OPML file.
// Export exports subscriptions as an OPML document.
func (c *Client) Export() ([]byte, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ExportContext(ctx)
}
// ExportContext creates OPML file.
// ExportContext exports subscriptions as an OPML document.
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
body, err := c.request.Get(ctx, "/v1/export")
if err != nil {
@@ -886,7 +888,7 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}
// Entries fetch entries.
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -911,7 +913,7 @@ func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResu
return &result, nil
}
// FeedEntries fetch feed entries.
// FeedEntries fetches entries for a feed using the given filter.
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -936,7 +938,7 @@ func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *F
return &result, nil
}
// CategoryEntries fetch entries of a category.
// CategoryEntries fetches entries for a category using the given filter.
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -1002,7 +1004,7 @@ func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryCha
return entry, nil
}
// ToggleStarred toggles entry starred value.
// ToggleStarred toggles the starred flag of an entry.
func (c *Client) ToggleStarred(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
+2 -2
View File
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
# Examples
This code snippet fetch the list of users:
This example fetches the list of users:
import (
miniflux "miniflux.app/v2/client"
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
}
fmt.Println(users, err)
This one discover subscriptions on a website:
This example discovers subscriptions on a website:
subscriptions, err := client.Discover("https://example.org/")
if err != nil {
+8 -2
View File
@@ -149,7 +149,7 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CheckedAt time.Time `json:"checked_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
@@ -166,6 +166,7 @@ type Feed struct {
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
@@ -185,6 +186,7 @@ type FeedCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
@@ -214,6 +216,7 @@ type FeedModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
@@ -356,6 +359,9 @@ type APIKeyCreationRequest struct {
Description string `json:"description"`
}
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
//
//go:fix inline
func SetOptionalField[T any](value T) *T {
return &value
return new(value)
}
+17 -19
View File
@@ -1,27 +1,28 @@
module miniflux.app/v2
// +heroku goVersion go1.24
// +heroku goVersion go1.26
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.15.0
github.com/go-webauthn/webauthn v0.16.1
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/lib/pq v1.11.2
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.8
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/term v0.38.0
github.com/tdewolff/minify/v2 v2.24.10
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
)
require (
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
require (
@@ -30,21 +31,18 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/tdewolff/parse/v2 v2.8.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.24.0
toolchain go1.24.1
go 1.26.0
+32 -30
View File
@@ -17,19 +17,21 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
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=
@@ -42,8 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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=
@@ -60,10 +62,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=
github.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=
github.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=
github.com/tdewolff/parse/v2 v2.8.10/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=
@@ -83,10 +85,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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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=
@@ -101,10 +103,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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
@@ -123,8 +125,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -134,8 +136,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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
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=
@@ -145,8 +147,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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=
+15 -15
View File
@@ -580,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -609,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
ExternalFontHosts: new(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -638,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
ExternalFontHosts: new("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
@@ -662,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
CustomJS: new("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -691,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("invalid_theme"),
Theme: new("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -721,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
@@ -1090,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
Title: new("new title"),
})
if err != nil {
t.Fatal(err)
@@ -1109,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
HideGlobally: new(true),
})
if err != nil {
t.Fatal(err)
@@ -1128,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
HideGlobally: new(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
@@ -1612,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
FeedURL: new("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
@@ -1653,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
FeedURL: new(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -1685,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: miniflux.SetOptionalField(int64(123456789)),
CategoryID: new(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -2718,8 +2718,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: miniflux.SetOptionalField("New title"),
Content: miniflux.SetOptionalField("New content"),
Title: new("New title"),
Content: new("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
+24 -24
View File
@@ -308,47 +308,47 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
return
}
var req EntryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&req); err != nil {
var importRequest entryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if req.URL == "" {
if importRequest.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}
if req.Status == "" {
req.Status = model.EntryStatusRead
if importRequest.Status == "" {
importRequest.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(req.Status); err != nil {
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
json.BadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = req.URL
entry.CommentsURL = req.CommentsURL
entry.Author = req.Author
entry.Tags = req.Tags
entry.URL = importRequest.URL
entry.CommentsURL = importRequest.CommentsURL
entry.Author = importRequest.Author
entry.Tags = importRequest.Tags
if req.PublishedAt > 0 {
entry.Date = time.Unix(req.PublishedAt, 0).UTC()
if importRequest.PublishedAt > 0 {
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if req.Title == "" {
if importRequest.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = req.Title
entry.Title = importRequest.Title
}
hashInput := req.ExternalID
hashInput := importRequest.ExternalID
if hashInput == "" {
hashInput = req.URL
hashInput = importRequest.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
@@ -363,8 +363,8 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
return
}
if req.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, req.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
if importRequest.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
@@ -377,13 +377,13 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, req.Status); err != nil {
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = req.Status
entry.Status = importRequest.Status
if req.Starred {
if importRequest.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
@@ -392,9 +392,9 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
}
if created {
json.Created(w, r, map[string]int64{"id": entry.ID})
json.Created(w, r, entryIDResponse{ID: entry.ID})
} else {
json.OK(w, r, map[string]int64{"id": entry.ID})
json.OK(w, r, entryIDResponse{ID: entry.ID})
}
}
@@ -454,7 +454,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
}
}
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
json.OK(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
-5
View File
@@ -13,11 +13,6 @@ import (
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if !h.store.HasFeedIcon(feedID) {
json.NotFound(w, r)
return
}
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, r, err)
+1 -1
View File
@@ -32,5 +32,5 @@ func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
return
}
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
json.Created(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
}
+29 -13
View File
@@ -18,24 +18,40 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
// EntryImportRequest represents a manually imported entry for a feed.
type EntryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type integrationsStatusResponse struct {
HasIntegrations bool `json:"has_integrations"`
}
type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
}
type entryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
type importFeedsResponse struct {
Message string `json:"message"`
}
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
+1 -7
View File
@@ -126,13 +126,7 @@ func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request)
hasIntegrations := h.store.HasSaveEntry(userID)
response := struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: hasIntegrations,
}
json.OK(w, r, response)
json.OK(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
}
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -47,7 +47,7 @@ func runCleanupTasks(store *storage.Storage) {
}
}
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
+25 -24
View File
@@ -5,6 +5,7 @@ package config // import "miniflux.app/v2/internal/config"
import (
"maps"
"net"
"net/url"
"slices"
"strings"
@@ -218,6 +219,11 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_BILIBILI_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
@@ -238,14 +244,6 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"FILTER_ENTRY_MAX_AGE_DAYS": {
parsedIntValue: 0,
rawValue: "0",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"FORCE_REFRESH_INTERVAL": {
parsedDuration: 30 * time.Minute,
rawValue: "30",
@@ -300,7 +298,7 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
@@ -360,11 +358,6 @@ func NewConfigOptions() *configOptions {
rawValue: "",
valueType: urlType,
},
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
parsedDuration: 120 * time.Second,
rawValue: "120",
@@ -572,6 +565,15 @@ func NewConfigOptions() *configOptions {
parsedStringList: []string{},
rawValue: "",
valueType: stringListType,
validator: func(rawValue string) error {
for ip := range strings.SplitSeq(rawValue, ",") {
if _, _, err := net.ParseCIDR(ip); err != nil {
return err
}
}
return nil
},
},
"WATCHDOG": {
parsedBoolValue: true,
@@ -718,10 +720,6 @@ func (c *configOptions) FetchYouTubeWatchTime() bool {
return c.options["FETCH_YOUTUBE_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FilterEntryMaxAgeDays() int {
return c.options["FILTER_ENTRY_MAX_AGE_DAYS"].parsedIntValue
}
func (c *configOptions) ForceRefreshInterval() time.Duration {
return c.options["FORCE_REFRESH_INTERVAL"].parsedDuration
}
@@ -793,8 +791,15 @@ func (c *configOptions) HTTPS() bool {
return c.options["HTTPS"].parsedBoolValue
}
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
if c == nil {
return false
}
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) InvidiousInstance() string {
@@ -849,10 +854,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
}
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
}
+38 -49
View File
@@ -1273,22 +1273,6 @@ func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
}
}
func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FilterEntryMaxAgeDays() != 0 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
}
if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FilterEntryMaxAgeDays() != 7 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
}
}
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1367,27 +1351,51 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
@@ -1458,30 +1466,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1659,6 +1643,11 @@ func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
}
// Test invalid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
}
}
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
+4
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@@ -119,6 +120,9 @@ func (cp *configParser) parseLines(lines []string) error {
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
}
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
+29 -1
View File
@@ -435,7 +435,7 @@ var migrations = [...]func(tx *sql.Tx) error{
hasExtra := false
if err := tx.QueryRow(`
SELECT true
SELECT true
FROM information_schema.columns
WHERE
table_name='users' AND
@@ -1403,4 +1403,32 @@ var migrations = [...]func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`
ALTER TABLE user_sessions
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
ALTER COLUMN ip SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN user_agent SET DEFAULT '',
ALTER COLUMN user_agent SET NOT NULL
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
}
+1 -1
View File
@@ -279,7 +279,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
if csvItemIDs != "" {
var itemIDs []int64
for _, strItemID := range strings.Split(csvItemIDs, ",") {
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
strItemID = strings.TrimSpace(strItemID)
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
itemIDs = append(itemIDs, itemID)
+39 -81
View File
@@ -38,6 +38,7 @@ var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
errSimultaneously = fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
)
// Serve handles Google Reader API calls.
@@ -69,12 +70,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = false
case StarredStream:
@@ -89,12 +90,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 and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = true
case StarredStream:
@@ -452,7 +453,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, quickAddResponse{
NumResults: 1,
Query: newFeed.FeedURL,
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
StreamID: feedPrefix + strconv.FormatInt(newFeed.ID, 10),
StreamName: newFeed.Title,
})
}
@@ -584,9 +585,8 @@ func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID
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 ""
}
return ""
}
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
@@ -697,9 +697,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
streamPrefix := fmt.Sprintf(userStreamPrefix, userID)
userReadingList := streamPrefix + readingListStreamSuffix
userRead := streamPrefix + readStreamSuffix
userStarred := streamPrefix + starredStreamSuffix
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
@@ -739,6 +740,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Items: make([]contentItem, len(entries)),
}
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
@@ -747,7 +749,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories := make([]string, 0)
categories = append(categories, userReadingList)
if entry.Feed.Category.Title != "" {
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
categories = append(categories, labelPrefix+entry.Feed.Category.Title)
}
if entry.Status == model.EntryStatusRead {
categories = append(categories, userRead)
@@ -789,7 +791,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Content: entry.Content,
},
Origin: contentItemOrigin{
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
StreamID: feedPrefix + strconv.FormatInt(entry.FeedID, 10),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
@@ -890,7 +892,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
}
categoryModificationRequest := model.CategoryModificationRequest{
Title: model.SetOptionalField(destination.ID),
Title: new(destination.ID),
}
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
@@ -933,9 +935,10 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
})
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
ID: labelPrefix + category.Title,
Label: category.Title,
Type: "folder",
})
@@ -965,13 +968,14 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
return
}
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
result.Subscriptions = make([]subscriptionResponse, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
ID: feedPrefix + strconv.FormatInt(feed.ID, 10),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
Categories: []subscriptionCategoryResponse{{labelPrefix + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: h.feedIconURL(feed),
})
@@ -1067,7 +1071,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
}
}
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle ReadingListStream",
@@ -1102,31 +1106,15 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
@@ -1139,32 +1127,15 @@ func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Requ
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStatus(model.EntryStatusRead)
@@ -1178,12 +1149,20 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestModifiers) ([]itemRef, int, error) {
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
return nil, 0, err
}
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
@@ -1191,18 +1170,16 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
return nil, 0, err
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
return itemRefs, continuation, nil
}
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
feedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64)
if err != nil {
json.ServerError(w, r, err)
@@ -1231,30 +1208,11 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
}
}
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
type requestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
@@ -24,7 +24,7 @@ type RequestModifiers struct {
UserID int64
}
func (r RequestModifiers) String() string {
func (r requestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
@@ -57,9 +57,9 @@ func (r RequestModifiers) String() string {
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
func parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
result := requestModifiers{
SortDirection: "desc",
UserID: userID,
}
@@ -71,16 +71,16 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/internal/http/client"
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
)
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
// Options holds configuration for creating an HTTP client.
type Options struct {
Timeout time.Duration
BlockPrivateNetworks bool
}
// NewClientWithOptions creates a new HTTP client with the specified options.
func NewClientWithOptions(opts Options) *http.Client {
if !opts.BlockPrivateNetworks {
return &http.Client{Timeout: opts.Timeout}
}
dialer := &net.Dialer{
Timeout: opts.Timeout,
}
transport := &http.Transport{
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
}
var safeIP net.IP
for _, ip := range ips {
if !urllib.IsNonPublicIP(ip) {
safeIP = ip
break
}
}
if safeIP == nil {
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
}
safeAddr := net.JoinHostPort(safeIP.String(), port)
return dialer.DialContext(ctx, network, safeAddr)
},
}
return &http.Client{
Timeout: opts.Timeout,
Transport: transport,
}
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err := client.Get(server.URL)
if err == nil {
t.Fatal("Expected an error when connecting to loopback address, got nil")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
}
}
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
if client == nil {
t.Fatal("Expected non-nil client")
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
}
if transport.DialContext == nil {
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
}
}
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
if client.Transport != nil {
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
}
}
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.Listener = listener
server.Start()
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err = client.Get(server.URL)
if err == nil {
t.Fatal("Expected error when connecting to private IP")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
}
}
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
+28 -12
View File
@@ -9,10 +9,10 @@ import (
"strings"
)
// IsTrustedIP checks if the given remote IP belongs to one of the trusted networks.
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
if remoteIP == "@" || strings.HasPrefix(remoteIP, "/") {
return true
if len(trustedNetworks) == 0 {
return false
}
ip := net.ParseIP(remoteIP)
@@ -34,7 +34,7 @@ func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
return false
}
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
if isTrustedProxyClient {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
@@ -57,19 +57,35 @@ func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
return FindRemoteIP(r)
}
// FindRemoteIP returns remote client IP address without considering HTTP headers.
// FindRemoteIP returns the parsed remote IP address from the request,
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
func FindRemoteIP(r *http.Request) string {
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteIP = r.RemoteAddr
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
return "127.0.0.1"
}
return dropIPv6zone(remoteIP)
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
ip = r.RemoteAddr
}
// Strip IPv6 zone identifier if present (e.g., %eth0).
ip = dropIPv6zone(ip)
// Validate the IP address.
if net.ParseIP(ip) == nil {
return "127.0.0.1"
}
return ip
}
func dropIPv6zone(address string) string {
i := strings.IndexByte(address, '%')
if i != -1 {
address = address[:i]
idx := strings.IndexByte(address, '%')
if idx != -1 {
address = address[:idx]
}
return address
}
+35 -10
View File
@@ -33,6 +33,16 @@ func TestFindClientIPWithoutHeaders(t *testing.T) {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: ""}
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestFindClientIPWithXFFHeader(t *testing.T) {
@@ -104,14 +114,6 @@ func TestClientIPWithBothHeaders(t *testing.T) {
}
}
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
r := &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r, false); ip != "@" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
@@ -136,8 +138,9 @@ func TestIsTrustedIP(t *testing.T) {
{"::1", true},
{"192.168.1.1", false},
{"invalid", false},
{"@", true},
{"/tmp/miniflux.sock", true},
{"@", false},
{"/tmp/miniflux.sock", false},
{"", false},
}
for _, scenario := range scenarios {
@@ -155,3 +158,25 @@ func TestIsTrustedIP(t *testing.T) {
t.Error("Expected false when trusted networks list is empty")
}
}
func TestFindRemoteIP(t *testing.T) {
scenarios := []struct {
ip string
expected string
}{
{"192.168.0.1:4242", "192.168.0.1"},
{"[2001:db8::1]:4242", "2001:db8::1"},
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
{"", "127.0.0.1"},
{"@", "127.0.0.1"},
{"invalid", "127.0.0.1"},
}
for _, scenario := range scenarios {
r := &http.Request{RemoteAddr: scenario.ip}
result := FindRemoteIP(r)
if result != scenario.expected {
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
}
}
}
+18 -16
View File
@@ -36,6 +36,7 @@ const (
WebAuthnDataContextKey
)
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid {
@@ -45,27 +46,27 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
return nil
}
// GoogleReaderToken returns the google reader token if it exists.
// GoogleReaderToken returns the Google Reader token from the request context, if present.
func GoogleReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderTokenKey)
}
// IsAdminUser checks if the logged user is administrator.
// IsAdminUser reports whether the logged-in user is an administrator.
func IsAdminUser(r *http.Request) bool {
return getContextBoolValue(r, IsAdminUserContextKey)
}
// IsAuthenticated returns a boolean if the user is authenticated.
// IsAuthenticated reports whether the user is authenticated.
func IsAuthenticated(r *http.Request) bool {
return getContextBoolValue(r, IsAuthenticatedContextKey)
}
// UserID returns the UserID of the logged user.
// UserID returns the logged-in user's ID from the request context.
func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
// UserName returns the logged-in user's username, or "unknown" when unset.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
@@ -74,7 +75,7 @@ func UserName(r *http.Request) string {
return value
}
// UserTimezone returns the timezone used by the logged user.
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
if value == "" {
@@ -83,7 +84,7 @@ func UserTimezone(r *http.Request) string {
return value
}
// UserLanguage get the locale used by the current logged user.
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
func UserLanguage(r *http.Request) string {
language := getContextStringValue(r, UserLanguageContextKey)
if language == "" {
@@ -92,7 +93,7 @@ func UserLanguage(r *http.Request) string {
return language
}
// UserTheme get the theme used by the current logged user.
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
func UserTheme(r *http.Request) string {
theme := getContextStringValue(r, UserThemeContextKey)
if theme == "" {
@@ -101,41 +102,42 @@ func UserTheme(r *http.Request) string {
return theme
}
// CSRF returns the current CSRF token.
// CSRF returns the CSRF token from the request context.
func CSRF(r *http.Request) string {
return getContextStringValue(r, CSRFContextKey)
}
// SessionID returns the current session ID.
// SessionID returns the current session ID from the request context.
func SessionID(r *http.Request) string {
return getContextStringValue(r, SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
// UserSessionToken returns the current user session token from the request context.
func UserSessionToken(r *http.Request) string {
return getContextStringValue(r, UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
// OAuth2State returns the OAuth2 state value from the request context.
func OAuth2State(r *http.Request) string {
return getContextStringValue(r, OAuth2StateContextKey)
}
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
func OAuth2CodeVerifier(r *http.Request) string {
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
}
// FlashMessage returns the message message if any.
// FlashMessage returns the flash message from the request context, if any.
func FlashMessage(r *http.Request) string {
return getContextStringValue(r, FlashMessageContextKey)
}
// FlashErrorMessage returns the message error message if any.
// FlashErrorMessage returns the flash error message from the request context, if any.
func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
// LastForceRefresh returns the last force refresh timestamp from the request context.
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
@@ -145,7 +147,7 @@ func LastForceRefresh(r *http.Request) time.Time {
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the context.
// ClientIP returns the client IP address stored in the request context.
func ClientIP(r *http.Request) string {
return getContextStringValue(r, ClientIPContextKey)
}
+130
View File
@@ -7,6 +7,9 @@ import (
"context"
"net/http"
"testing"
"time"
"miniflux.app/v2/internal/model"
)
func TestContextStringValue(t *testing.T) {
@@ -192,6 +195,28 @@ func TestUserID(t *testing.T) {
}
}
func TestUserName(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserName(r)
expected := "unknown"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
r = r.WithContext(ctx)
result = UserName(r)
expected = "jane"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserTimezone(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -346,6 +371,28 @@ func TestOAuth2State(t *testing.T) {
}
}
func TestOAuth2CodeVerifier(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2CodeVerifier(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
r = r.WithContext(ctx)
result = OAuth2CodeVerifier(r)
expected = "verifier"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -390,6 +437,67 @@ func TestFlashErrorMessage(t *testing.T) {
}
}
func TestLastForceRefresh(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := LastForceRefresh(r)
expected := time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx = r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Unix(1700000000, 0)
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestWebAuthnSessionData(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
ctx := r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
session := model.WebAuthnSession{}
ctx = r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result == nil {
t.Errorf("Unexpected context value, got nil instead of session")
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -411,3 +519,25 @@ func TestClientIP(t *testing.T) {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestGoogleReaderToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := GoogleReaderToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
r = r.WithContext(ctx)
result = GoogleReaderToken(r)
expected = "token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
import "net/http"
// CookieValue returns the cookie value.
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
func CookieValue(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
+9 -9
View File
@@ -11,7 +11,7 @@ import (
"github.com/gorilla/mux"
)
// FormInt64Value returns a form value as integer.
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
func FormInt64Value(r *http.Request, param string) int64 {
value := r.FormValue(param)
integer, err := strconv.ParseInt(value, 10, 64)
@@ -22,7 +22,7 @@ func FormInt64Value(r *http.Request, param string) int64 {
return integer
}
// RouteInt64Param returns an URL route parameter as int64.
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
func RouteInt64Param(r *http.Request, param string) int64 {
vars := mux.Vars(r)
value, err := strconv.ParseInt(vars[param], 10, 64)
@@ -37,13 +37,13 @@ func RouteInt64Param(r *http.Request, param string) int64 {
return value
}
// RouteStringParam returns a URL route parameter as string.
// RouteStringParam returns the named route parameter as a string.
func RouteStringParam(r *http.Request, param string) string {
vars := mux.Vars(r)
return vars[param]
}
// QueryStringParam returns a query string parameter as string.
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
func QueryStringParam(r *http.Request, param, defaultValue string) string {
value := r.URL.Query().Get(param)
if value == "" {
@@ -52,7 +52,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
return value
}
// QueryStringParamList returns all values associated to the parameter.
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
func QueryStringParamList(r *http.Request, param string) []string {
var results []string
values := r.URL.Query()
@@ -69,7 +69,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
return results
}
// QueryIntParam returns a query string parameter as integer.
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
value := r.URL.Query().Get(param)
if value == "" {
@@ -88,7 +88,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
return int(val)
}
// QueryInt64Param returns a query string parameter as int64.
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
value := r.URL.Query().Get(param)
if value == "" {
@@ -107,7 +107,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
return val
}
// QueryBoolParam returns a query string parameter as bool.
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
value := r.URL.Query().Get(param)
if value == "" {
@@ -123,7 +123,7 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
return val
}
// HasQueryParam checks if the query string contains the given parameter.
// HasQueryParam reports whether the query string contains the named parameter.
func HasQueryParam(r *http.Request, param string) bool {
values := r.URL.Query()
_, ok := values[param]
+54 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/gorilla/mux"
@@ -179,7 +180,7 @@ func TestQueryInt64Param(t *testing.T) {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryInt64Param(r, "invalid", int64(69))
result = QueryInt64Param(r, "negative", int64(69))
expected = int64(69)
if result != expected {
@@ -194,6 +195,58 @@ func TestQueryInt64Param(t *testing.T) {
}
}
func TestQueryBoolParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
r := &http.Request{URL: u}
result := QueryBoolParam(r, "truthy", false)
expected := true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "falsy", true)
expected = false
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "missing", true)
expected = true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "invalid", true)
expected = true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
func TestQueryStringParamList(t *testing.T) {
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
r := &http.Request{URL: u}
result := QueryStringParamList(r, "tag")
expected := []string{"alpha", "beta", "gamma"}
if !reflect.DeepEqual(result, expected) {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryStringParamList(r, "missing")
expected = nil
if !reflect.DeepEqual(result, expected) {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
func TestHasQueryParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=42")
r := &http.Request{URL: u}
+9 -15
View File
@@ -20,7 +20,6 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui"
"miniflux.app/v2/internal/version"
"miniflux.app/v2/internal/worker"
"github.com/gorilla/mux"
@@ -72,22 +71,23 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
Handler: setupHandler(store, pool),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
isUNIXSocket := strings.HasPrefix(listenAddr, "/")
isListenPID := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
if !isUNIXSocket && !isListenPID {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
case isListenPID:
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
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.", slog.String("skipped_address", listenAddr))
continue
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
case isUNIXSocket:
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
@@ -101,9 +101,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
startHTTPServer(server)
}
if shouldAddServer {
httpServers = append(httpServers, server)
}
httpServers = append(httpServers, server)
}
return httpServers
@@ -250,10 +248,6 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -25,7 +27,7 @@ type Client struct {
}
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
return &Client{servicesURL: serviceURL, baseURL: baseURL}
}
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
@@ -65,12 +67,12 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+28 -23
View File
@@ -4,11 +4,17 @@
package archiveorg
import (
"log/slog"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
@@ -18,26 +24,25 @@ func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
func (c *Client) SendURL(entryURL string) error {
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("archiveorg: unable to create request: %v", err)
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("archiveorg: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
}
return nil
}
+3 -2
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
return fmt.Errorf("betula: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("betula: unable to send request: %v", err)
+3 -1
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -77,12 +79,12 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
@@ -10,6 +10,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
}
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
+29 -24
View File
@@ -409,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.WebhookEnabled {
@@ -447,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
if err := client.SaveUrl(entry.URL); err != nil {
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
@@ -543,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Warn("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),
@@ -642,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
client := pushover.NewClient(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
@@ -658,30 +665,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ type errorResponse struct {
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
URL string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
+3 -1
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
+55 -31
View File
@@ -10,9 +10,13 @@ import (
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
apiToken string
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "Invalid input",
},
},
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
description := input["description"].(string)
if len(description) != maxDescriptionLength {
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
}
func TestGraphQLMutation(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
variables, ok := req["variables"].(map[string]any)
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
input, ok := variables["input"].(map[string]any)
if !ok {
t.Fatal("Missing input in variables")
}
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
_ = strings.Join(splitTags, ",")
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
@@ -11,10 +11,13 @@ import (
"strings"
"testing"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
baseURL string
@@ -323,3 +326,21 @@ func TestNewClient(t *testing.T) {
})
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+5 -3
View File
@@ -11,7 +11,9 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,7 +42,7 @@ func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -89,7 +91,7 @@ func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*L
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -138,7 +140,7 @@ func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, te
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -57,7 +58,7 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
+12 -2
View File
@@ -13,6 +13,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -32,7 +33,16 @@ func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntf
if ntfyURL == "" {
ntfyURL = defaultNtfyURL
}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
return &Client{
ntfyURL: ntfyURL,
ntfyTopic: ntfyTopic,
ntfyApiToken: ntfyApiToken,
ntfyUsername: ntfyUsername,
ntfyPassword: ntfyPassword,
ntfyIconURL: ntfyIconURL,
ntfyInternalLinks: ntfyInternalLinks,
ntfyPriority: ntfyPriority,
}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
@@ -98,7 +108,7 @@ func (c *Client) makeRequest(payload any) error {
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("ntfy: unable to send request: %v", err)
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -43,7 +45,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
ContentType: "text/html",
})
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
return fmt.Errorf("nunux-keeper: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
@@ -55,7 +57,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
+15 -20
View File
@@ -11,7 +11,9 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -34,12 +36,6 @@ mutation SaveUrl($input: SaveUrlInput!) {
}
`
type SaveUrlInput struct {
ClientRequestId string `json:"clientRequestId"`
Source string `json:"source"`
Url string `json:"url"`
}
type errorResponse struct {
Errors []struct {
Message string `json:"message"`
@@ -49,31 +45,27 @@ type errorResponse struct {
type successResponse struct {
Data struct {
SaveUrl struct {
Url string `json:"url"`
ClientRequestId string `json:"clientRequestId"`
URL string `json:"url"`
ClientRequestID string `json:"clientRequestId"`
} `json:"saveUrl"`
} `json:"data"`
}
type Client interface {
SaveUrl(url string) error
}
type client struct {
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
}
func NewClient(apiToken string, apiEndpoint string) Client {
func NewClient(apiToken string, apiEndpoint string) *Client {
if apiEndpoint == "" {
apiEndpoint = defaultApiEndpoint
}
return &client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}
}
func (c *client) SaveUrl(url string) error {
func (c *Client) SaveURL(url string) error {
var payload = map[string]any{
"query": mutation,
"variables": map[string]any{
@@ -105,7 +97,7 @@ func (c *client) SaveUrl(url string) error {
defer resp.Body.Close()
b, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("omnivore: failed to parse response: %s", err)
return fmt.Errorf("omnivore: failed to parse response: %v", err)
}
if resp.StatusCode >= 400 {
@@ -113,11 +105,14 @@ func (c *client) SaveUrl(url string) error {
if err = json.Unmarshal(b, &errResponse); err != nil {
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
}
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
if len(errResponse.Errors) > 0 {
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
}
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
}
var successReponse successResponse
if err = json.Unmarshal(b, &successReponse); err != nil {
var successResp successResponse
if err = json.Unmarshal(b, &successResp); err != nil {
return fmt.Errorf("omnivore: failed to parse response, however the request appears successful, is the url correct?: status=%d %s", resp.StatusCode, string(b))
}
+3 -4
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -57,10 +58,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
return fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pinboard: unable to send request: %v", err)
@@ -90,10 +90,9 @@ func (c *Client) getBookmark(entryURL string) (*Post, error) {
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
+3 -3
View File
@@ -13,7 +13,7 @@ import (
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
type Post struct {
XMLName xml.Name `xml:"post"`
Url string `xml:"href,attr"`
URL string `xml:"href,attr"`
Description string `xml:"description,attr"`
Tags string `xml:"tag,attr"`
Extended string `xml:"extended,attr"`
@@ -30,7 +30,7 @@ type posts struct {
func NewPost(url string, description string) *Post {
return &Post{
Url: url,
URL: url,
Description: description,
Date: time.Now(),
Toread: "no",
@@ -48,7 +48,7 @@ func (p *Post) SetToread() {
}
func (p *Post) AddValues(values url.Values) {
values.Add("url", p.Url)
values.Add("url", p.URL)
values.Add("description", p.Description)
values.Add("tags", p.Tags)
if p.Toread != "" {
+15 -13
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -31,7 +33,7 @@ type Client struct {
priority int
}
type Message struct {
type message struct {
Token string `json:"token"`
User string `json:"user"`
@@ -44,14 +46,14 @@ type Message struct {
Device string `json:"device,omitempty"`
}
type ErrorResponse struct {
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 {
func NewClient(user, token string, priority int, device, urlPrefix string) *Client {
if urlPrefix == "" {
urlPrefix = defaultPushoverURL
}
@@ -76,7 +78,7 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
return errors.New("pushover token and user are required")
}
for _, entry := range entries {
msg := &Message{
msg := &message{
User: c.user,
Token: c.token,
Device: c.device,
@@ -94,45 +96,45 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
)
if err := c.makeRequest(msg); err != nil {
return fmt.Errorf("c.makeRequest: %w", err)
return fmt.Errorf("pushover: unable to send message: %w", err)
}
}
return nil
}
func (c *Client) makeRequest(payload *Message) error {
func (c *Client) makeRequest(payload *message) error {
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
return fmt.Errorf("pushover: unable to encode request body: %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)
return fmt.Errorf("pushover: unable to create request: %w", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("httpClient.Do: %w", err)
return fmt.Errorf("pushover: unable to send request: %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 {
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 fmt.Errorf("pushover: API error: status=%d %s", resp.StatusCode, errorMessage)
}
return nil
+4 -2
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -53,7 +55,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
@@ -70,7 +72,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection,omitempty"`
Collection collection `json:"collection"`
Tags []string `json:"tags"`
}
+7 -5
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
var request *http.Request
if c.onlyURL {
requestBodyJson, err := json.Marshal(&readeckBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Labels: labelsSplit,
})
@@ -91,7 +93,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
}
contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL,
URL: entryURL,
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
})
if err != nil {
@@ -120,7 +122,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readeck: unable to send request: %v", err)
@@ -135,7 +137,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
}
type readeckBookmark struct {
Url string `json:"url"`
URL string `json:"url"`
Title string `json:"title"`
Labels []string `json:"labels,omitempty"`
}
@@ -145,6 +147,6 @@ type contentHeader struct {
}
type partContentHeader struct {
Url string `json:"url"`
URL string `json:"url"`
ContentHeader contentHeader `json:"headers"`
}
+259 -237
View File
@@ -4,257 +4,279 @@
package readeck
import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateBookmark(t *testing.T) {
entryURL := "https://example.com/article"
entryTitle := "Example Title"
entryContent := "<p>Some HTML content</p>"
labels := "tag1,tag2"
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
onlyURL bool
baseURL string
apiKey string
labels string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation with only URL",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}
entryURL := "https://example.com/article"
entryTitle := "Example Title"
entryContent := "<p>Some HTML content</p>"
labels := "tag1,tag2"
body, _ := io.ReadAll(r.Body)
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("failed to parse JSON body: %v", err)
}
if u := payload["url"]; u != entryURL {
t.Errorf("expected url %s, got %v", entryURL, u)
}
if title := payload["title"]; title != entryTitle {
t.Errorf("expected title %s, got %v", entryTitle, title)
}
// Labels should be split into an array
if raw := payload["labels"]; raw == nil {
t.Errorf("expected labels to be set")
} else if arr, ok := raw.([]any); ok {
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
t.Errorf("unexpected labels: %#v", arr)
}
} else {
t.Errorf("labels should be an array, got %T", raw)
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "successful bookmark creation with content (multipart)",
onlyURL: false,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data;") {
t.Errorf("expected multipart/form-data, got %s", ct)
}
boundaryIdx := strings.Index(ct, "boundary=")
if boundaryIdx == -1 {
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
}
boundary := ct[boundaryIdx+len("boundary="):]
mr := multipart.NewReader(r.Body, boundary)
tests := []struct {
name string
onlyURL bool
baseURL string
apiKey string
labels string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation with only URL",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}
seenLabels := []string{}
var seenURL, seenTitle, seenFeature string
var resourceHeader map[string]any
var resourceBody string
body, _ := io.ReadAll(r.Body)
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("failed to parse JSON body: %v", err)
}
if u := payload["url"]; u != entryURL {
t.Errorf("expected url %s, got %v", entryURL, u)
}
if title := payload["title"]; title != entryTitle {
t.Errorf("expected title %s, got %v", entryTitle, title)
}
// Labels should be split into an array
if raw := payload["labels"]; raw == nil {
t.Errorf("expected labels to be set")
} else if arr, ok := raw.([]any); ok {
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
t.Errorf("unexpected labels: %#v", arr)
}
} else {
t.Errorf("labels should be an array, got %T", raw)
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "successful bookmark creation with content (multipart)",
onlyURL: false,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data;") {
t.Errorf("expected multipart/form-data, got %s", ct)
}
_, after, ok := strings.Cut(ct, "boundary=")
if !ok {
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
}
boundary := after
mr := multipart.NewReader(r.Body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading multipart: %v", err)
}
name := part.FormName()
data, _ := io.ReadAll(part)
switch name {
case "url":
seenURL = string(data)
case "title":
seenTitle = string(data)
case "feature_find_main":
seenFeature = string(data)
case "labels":
seenLabels = append(seenLabels, string(data))
case "resource":
// First line is JSON header, then newline, then content
all := string(data)
idx := strings.IndexByte(all, '\n')
if idx == -1 {
t.Fatalf("resource content missing header separator")
}
headerJSON := all[:idx]
resourceBody = all[idx+1:]
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
t.Fatalf("invalid resource header JSON: %v", err)
}
}
}
seenLabels := []string{}
var seenURL, seenTitle, seenFeature string
var resourceHeader map[string]any
var resourceBody string
if seenURL != entryURL {
t.Errorf("expected url %s, got %s", entryURL, seenURL)
}
if seenTitle != entryTitle {
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
}
if seenFeature != "false" {
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
}
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
t.Errorf("unexpected labels: %#v", seenLabels)
}
if resourceHeader == nil {
t.Fatalf("missing resource header")
}
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
}
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
}
} else {
t.Errorf("missing resource header 'headers' field")
}
if resourceBody != entryContent {
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading multipart: %v", err)
}
name := part.FormName()
data, _ := io.ReadAll(part)
switch name {
case "url":
seenURL = string(data)
case "title":
seenTitle = string(data)
case "feature_find_main":
seenFeature = string(data)
case "labels":
seenLabels = append(seenLabels, string(data))
case "resource":
// First line is JSON header, then newline, then content
all := string(data)
before, after, ok := strings.Cut(all, "\n")
if !ok {
t.Fatalf("resource content missing header separator")
}
headerJSON := before
resourceBody = after
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
t.Fatalf("invalid resource header JSON: %v", err)
}
}
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "error when server returns 400",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
},
wantErr: true,
errContains: "unable to create bookmark",
},
{
name: "error when missing baseURL or apiKey",
onlyURL: true,
baseURL: "",
apiKey: "",
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: nil,
wantErr: true,
errContains: "missing base URL or API key",
},
}
if seenURL != entryURL {
t.Errorf("expected url %s, got %s", entryURL, seenURL)
}
if seenTitle != entryTitle {
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
}
if seenFeature != "false" {
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
}
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
t.Errorf("unexpected labels: %#v", seenLabels)
}
if resourceHeader == nil {
t.Fatalf("missing resource header")
}
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
}
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
}
} else {
t.Errorf("missing resource header 'headers' field")
}
if resourceBody != entryContent {
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var serverURL string
if tt.serverResponse != nil {
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer srv.Close()
serverURL = srv.URL
}
baseURL := tt.baseURL
if baseURL == "" {
baseURL = serverURL
}
apiKey := tt.apiKey
if apiKey == "" {
apiKey = "test-api-key"
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "error when server returns 400",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
},
wantErr: true,
errContains: "unable to create bookmark",
},
{
name: "error when missing baseURL or apiKey",
onlyURL: true,
baseURL: "",
apiKey: "",
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: nil,
wantErr: true,
errContains: "missing base URL or API key",
},
}
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var serverURL string
if tt.serverResponse != nil {
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer srv.Close()
serverURL = srv.URL
}
baseURL := tt.baseURL
if baseURL == "" {
baseURL = serverURL
}
apiKey := tt.apiKey
if apiKey == "" {
apiKey = "test-api-key"
}
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none")
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none")
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNewClient(t *testing.T) {
baseURL := "https://readeck.example.com"
apiKey := "key"
labels := "tag1,tag2"
onlyURL := true
baseURL := "https://readeck.example.com"
apiKey := "key"
labels := "tag1,tag2"
onlyURL := true
c := NewClient(baseURL, apiKey, labels, onlyURL)
if c.baseURL != baseURL {
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
}
if c.apiKey != apiKey {
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
}
if c.labels != labels {
t.Errorf("expected labels %s, got %s", labels, c.labels)
}
if c.onlyURL != onlyURL {
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
}
c := NewClient(baseURL, apiKey, labels, onlyURL)
if c.baseURL != baseURL {
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
}
if c.apiKey != apiKey {
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
}
if c.labels != labels {
t.Errorf("expected labels %s, got %s", labels, c.labels)
}
if c.onlyURL != onlyURL {
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) CreateDocument(entryURL string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
+14 -8
View File
@@ -11,6 +11,10 @@ import (
"net/url"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
@@ -27,7 +31,7 @@ type BridgeMeta struct {
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)
return nil, fmt.Errorf("rssbridge: unable to parse bridge URL: %w", err)
}
values := endpointURL.Query()
@@ -43,14 +47,16 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
return nil, fmt.Errorf("rssbridge: unable to create request: %w", err)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
return nil, fmt.Errorf("rssbridge: unable to execute request: %w", err)
}
defer response.Body.Close()
@@ -58,13 +64,13 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
return nil, nil
}
if response.StatusCode > 400 {
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
if response.StatusCode >= 400 {
return nil, fmt.Errorf("rssbridge: unexpected status code %d", response.StatusCode)
}
var bridgeResponse []*Bridge
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
return nil, fmt.Errorf("rssbridge: unable to decode bridge response: %w", err)
}
for _, bridge := range bridgeResponse {
@@ -76,7 +82,7 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
if strings.HasPrefix(bridge.URL, "./") {
bridge.URL = rssBridgeURL + bridge.URL[2:]
slog.Debug("Rewrited relative RSS bridge URL",
slog.Debug("Rewrote relative RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
+4 -2
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -59,7 +61,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shaarli: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
func (c *Client) generateBearerToken() string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
payload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"iat":%d}`, time.Now().Unix()))
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret))
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -65,7 +67,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
@@ -80,7 +82,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return nil
}
func (c *Client) authenticate() (token string, err error) {
func (c *Client) authenticate() (string, error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
@@ -100,7 +102,7 @@ func (c *Client) authenticate() (token string, err error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -81,12 +83,12 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -50,7 +51,7 @@ func (c *Client) GetMe() (*User, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
@@ -90,7 +91,7 @@ func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
+18 -8
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -30,7 +32,15 @@ type Client struct {
}
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
return &Client{
baseURL: baseURL,
clientID: clientID,
clientSecret: clientSecret,
username: username,
password: password,
tags: tags,
onlyURL: onlyURL,
}
}
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
@@ -49,7 +59,7 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
return fmt.Errorf("wallabag: unable to generate entries endpoint: %v", err)
}
if c.onlyURL {
@@ -63,12 +73,12 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
Tags: tags,
})
if err != nil {
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
return fmt.Errorf("wallabag: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("wallbag: unable to create request: %v", err)
return fmt.Errorf("wallabag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -76,7 +86,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("wallabag: unable to send request: %v", err)
@@ -100,19 +110,19 @@ func (c *Client) getAccessToken() (string, error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
if err != nil {
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
return "", fmt.Errorf("wallabag: unable to generate token endpoint: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return "", fmt.Errorf("wallbag: unable to create request: %v", err)
return "", fmt.Errorf("wallabag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
@@ -10,9 +10,13 @@ import (
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateEntry(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
entryURL := "https://example.com"
entryTitle := "title"
entryContent := "content"
@@ -319,3 +323,21 @@ func TestNewClient(t *testing.T) {
})
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+3 -1
View File
@@ -11,7 +11,9 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -132,7 +134,7 @@ func (c *Client) makeRequest(eventType string, payload any) error {
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
request.Header.Set("X-Miniflux-Event-Type", eventType)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("webhook: unable to send request: %v", err)
+2
View File
@@ -99,12 +99,14 @@ func TestMissingTranslations(t *testing.T) {
func TestTranslationFilePluralForms(t *testing.T) {
var numberOfPluralFormsPerLanguage = map[string]int{
"ar_SA": 6,
"de_DE": 2,
"el_EL": 2,
"en_US": 2,
"es_ES": 2,
"fi_FI": 2,
"fr_FR": 2,
"gl_ES": 2,
"hi_IN": 2,
"id_ID": 1,
"it_IT": 2,
+2
View File
@@ -5,12 +5,14 @@ package locale // import "miniflux.app/v2/internal/locale"
// AvailableLanguages is the list of available languages.
var AvailableLanguages = map[string]string{
"ar_SA": "العربية",
"de_DE": "Deutsch",
"el_EL": "Ελληνικά",
"en_US": "English",
"es_ES": "Español",
"fi_FI": "Suomi",
"fr_FR": "Français",
"gl_ES": "Galego",
"hi_IN": "हिन्दी",
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
+5
View File
@@ -31,6 +31,11 @@ func getPluralForm(lang string, n int) int {
default:
return 2
}
case "gl_ES":
if n != 1 {
return 1
}
return 0
case "id_ID", "ja_JP":
return 0
case "pl_PL":
+7
View File
@@ -44,6 +44,13 @@ func TestPluralRules(t *testing.T) {
2: 1, // n > 1
5: 1, // n > 1
},
// Galician (gl_ES) - n != 1
"gl_ES": {
0: 1, // n != 1
1: 0, // n == 1
2: 1, // n != 1
5: 1, // n != 1
},
// Indonesian (id_ID) - always form 0
"id_ID": {
0: 0,
+707
View File
@@ -0,0 +1,707 @@
{
"action.cancel": "إلغاء",
"action.download": "تحميل",
"action.edit": "تعديل",
"action.home_screen": "إضافة إلى الشاشة الرئيسية",
"action.import": "استيراد",
"action.login": "تسجيل الدخول",
"action.or": "أو",
"action.remove": "حذف",
"action.remove_feed": "حذف هذا المصدر",
"action.save": "حفظ",
"action.subscribe": "اشتراك",
"action.update": "تحديث",
"alert.account_linked": "تم ربط حسابك الخارجي!",
"alert.account_unlinked": "تم فك ارتباط حسابك الخارجي!",
"alert.background_feed_refresh": "يتم تحديث جميع المصادر في الخلفية. يمكنك الاستمرار في استخدام Miniflux أثناء تشغيل هذه العملية.",
"alert.feed_error": "توجد مشكلة في هذا المصدر",
"alert.no_starred": "لا توجد في المُفضلة.",
"alert.no_category": "لا توجد فئة.",
"alert.no_category_entry": "لا توجد مقالات في هذه الفئة.",
"alert.no_feed": "ليس لديك أي مصادر.",
"alert.no_feed_entry": "لا توجد مقالات لهذا المصدر.",
"alert.no_feed_in_category": "لا يوجد مصدر لهذه الفئة.",
"alert.no_history": "لا يوجد سجل في الوقت الحالي.",
"alert.no_search_result": "لا توجد نتائج لهذا البحث.",
"alert.no_shared_entry": "لا توجد مشاركات.",
"alert.no_tag_entry": "لا توجد مقالات تطابق هذا الوسم.",
"alert.no_unread_entry": "لا توجد مقالات غير مقروءة.",
"alert.no_user": "أنت المستخدم الوحيد.",
"alert.prefs_saved": "تم حفظ التفضيلات!",
"alert.too_many_feeds_refresh": [
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقة واحدة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقتين قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقائق قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى."
],
"confirm.loading": "جارٍ التحميل...",
"confirm.no": "لا",
"confirm.question": "هل أنت متأكد؟",
"confirm.question.refresh": "هل أنت متأكد أنك تريد فرض التحديث؟",
"confirm.yes": "نعم",
"enclosure_media_controls.seek": "بحث:",
"enclosure_media_controls.seek.title": "بحث %s ثانية",
"enclosure_media_controls.speed": "السرعة:",
"enclosure_media_controls.speed.faster": "أسرع",
"enclosure_media_controls.speed.faster.title": "أسرع بـ %sx",
"enclosure_media_controls.speed.reset": "إعادة تعيين",
"enclosure_media_controls.speed.reset.title": "إعادة تعيين السرعة إلى 1x",
"enclosure_media_controls.speed.slower": "أبطأ",
"enclosure_media_controls.speed.slower.title": "أبطأ بـ %sx",
"entry.starred.toast.off": "أزيلت من المفضلة",
"entry.starred.toast.on": "أضيفت للمفضلة",
"entry.starred.toggle.off": "إزالة من المفضلة",
"entry.starred.toggle.on": "إضافة للمفضلة",
"entry.comments.label": "تعليقات",
"entry.comments.title": "عرض التعليقات",
"entry.estimated_reading_time": [
"قراءة %d دقيقة",
"قراءة دقيقة واحدة",
"قراءة دقيقتين",
"قراءة %d دقائق",
"قراءة %d دقيقة",
"قراءة %d دقيقة"
],
"entry.external_link.label": "رابط خارجي",
"entry.save.completed": "تم!",
"entry.save.label": "حفظ",
"entry.save.title": "حفظ هذا المقال",
"entry.save.toast.completed": "تم حفظ المقال",
"entry.scraper.completed": "تم!",
"entry.scraper.label": "تحميل",
"entry.scraper.title": "جلب المحتوى الأصلي",
"entry.share.label": "مشاركة",
"entry.share.title": "مشاركة هذا المقال",
"entry.shared_entry.label": "مشاركة",
"entry.shared_entry.title": "فتح الرابط العام",
"entry.state.loading": "جارٍ التحميل...",
"entry.state.saving": "جارٍ الحفظ...",
"entry.status.mark_as_read": "تحديد كمقروء",
"entry.status.mark_as_unread": "تحديد كغير مقروء",
"entry.status.title": "تغيير حالة المقال",
"entry.status.toast.read": "تم تحديده كمقروء",
"entry.status.toast.unread": "تم تحديده كغير مقروء",
"entry.tags.label": "الوسوم:",
"entry.tags.more_tags_label": [
"إظهار %d وسم",
"إظهار وسم واحد",
"إظهار وسمين",
"إظهار %d وسوم",
"إظهار %d وسماً",
"إظهار %d وسماً"
],
"entry.unshare.label": "إلغاء المشاركة",
"error.api_key_already_exists": "مفتاح API هذا موجود بالفعل.",
"error.bad_credentials": "اسم المستخدم أو كلمة المرور غير صالحة.",
"error.category_already_exists": "هذه الفئة موجودة بالفعل.",
"error.category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
"error.database_error": "خطأ في قاعدة البيانات: %v.",
"error.different_passwords": "كلمات المرور غير متطابقة.",
"error.duplicate_fever_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Fever!",
"error.duplicate_googlereader_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Google Reader!",
"error.linktaco_missing_required_fields": "مطلوب رمز LinkTaco API و Organization Slug",
"error.duplicate_linked_account": "يوجد بالفعل شخص مرتبط بهذا الموفر!",
"error.duplicated_feed": "هذا المصدر موجود بالفعل.",
"error.empty_file": "هذا الملف فارغ.",
"error.entries_per_page_invalid": "عدد المقالات في الصفحة غير صالح.",
"error.feed_already_exists": "هذا المصدر موجود بالفعل.",
"error.feed_category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
"error.feed_format_not_detected": "تعذر اكتشاف تنسيق المصدر: %v.",
"error.feed_invalid_blocklist_rule": "قاعدة قائمة الحظر غير صالحة.",
"error.feed_invalid_keeplist_rule": "قاعدة قائمة الاحتفاظ غير صالحة.",
"error.feed_mandatory_fields": "الرابط والفئة إلزاميان.",
"error.feed_not_found": "هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.",
"error.feed_title_not_empty": "عنوان المصدر لا يمكن أن يكون فارغاً.",
"error.feed_url_not_empty": "رابط المصدر لا يمكن أن يكون فارغاً.",
"error.fields_mandatory": "جميع الحقول إلزامية.",
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
"error.http_client_error": "خطأ في عميل HTTP: %v.",
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
"error.http_gateway_timeout": "الموقع غير متاح حالياً بسبب انتهاء مهلة البوابة. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_internal_server_error": "الموقع غير متاح حالياً بسبب خطأ في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_not_authorized": "الوصول إلى هذا الموقع غير مصرح به. قد يكون اسم المستخدم أو كلمة المرور غير صحيحة.",
"error.http_resource_not_found": "المورد المطلوب غير موجود. يرجى التحقق من الرابط.",
"error.http_response_too_large": "استجابة HTTP كبيرة جداً. يمكنك زيادة حد حجم استجابة HTTP في الإعدادات العامة (يتطلب إعادة تشغيل الخادم).",
"error.http_service_unavailable": "الموقع غير متاح حالياً بسبب خطأ داخلي في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_too_many_requests": "أنشأ Miniflux عدداً كبيراً جداً من الطلبات لهذا الموقع. يرجى المحاولة لاحقاً أو تغيير إعدادات التطبيق.",
"error.http_unexpected_status_code": "الموقع غير متاح حالياً بسبب رمز حالة HTTP غير متوقع: %d. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.invalid_categories_sorting_order": "ترتيب فرز الفئات غير صالح.",
"error.invalid_default_home_page": "الصفحة الرئيسية الافتراضية غير صالحة!",
"error.invalid_display_mode": "وضع عرض تطبيق الويب غير صالح.",
"error.invalid_entry_direction": "اتجاه المقال غير صالح.",
"error.invalid_entry_order": "ترتيب المقال غير صالح.",
"error.invalid_feed_proxy_url": "رابط الوكيل (Proxy) غير صالح.",
"error.invalid_feed_url": "رابط المصدر غير صالح.",
"error.invalid_gesture_nav": "تصفح الإيماءات غير صالح.",
"error.invalid_language": "اللغة غير صالحة.",
"error.invalid_site_url": "رابط الموقع غير صالح.",
"error.invalid_theme": "السمة غير صالحة.",
"error.invalid_timezone": "المنطقة الزمنية غير صالحة.",
"error.network_operation": "Miniflux غير قادر على الوصول إلى هذا الموقع بسبب خطأ في الشبكة: %v.",
"error.network_timeout": "هذا الموقع بطيء جداً وانتهى وقت الطلب: %v",
"error.password_min_length": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.",
"error.proxy_url_not_empty": "رابط الوكيل لا يمكن أن يكون فارغاً.",
"error.settings_block_rule_fieldname_invalid": "قاعدة الحظر غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
"error.settings_block_rule_invalid_regex": "قاعدة الحظر غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
"error.settings_block_rule_regex_required": "قاعدة الحظر غير صالحة: لم يتم توفير نمط للقاعدة #%d",
"error.settings_block_rule_separator_required": "قاعدة الحظر غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
"error.settings_invalid_domain_list": "قائمة النطاقات غير صالحة. يرجى تقديم قائمة مفصولة بمسافات للنطاقات.",
"error.settings_keep_rule_fieldname_invalid": "قاعدة الاحتفاظ غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
"error.settings_keep_rule_invalid_regex": "قاعدة الاحتفاظ غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
"error.settings_keep_rule_regex_required": "قاعدة الاحتفاظ غير صالحة: لم يتم توفير نمط للقاعدة #%d",
"error.settings_keep_rule_separator_required": "قاعدة الاحتفاظ غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
"error.settings_mandatory_fields": "حقول اسم المستخدم، السمة، اللغة، والمنطقة الزمنية إلزامية.",
"error.settings_media_playback_rate_range": "سرعة التشغيل خارج النطاق",
"error.settings_reading_speed_is_positive": "يجب أن تكون سرعة القراءة أرقاماً صحيحة موجبة.",
"error.site_url_not_empty": "رابط الموقع لا يمكن أن يكون فارغاً.",
"error.subscription_not_found": "تعذر العثور على أي مصدر.",
"error.title_required": "العنوان إلزامي.",
"error.tls_error": "خطأ TLS: %q. يمكنك تعطيل التحقق من TLS في إعدادات المصدر إذا كنت ترغب في ذلك.",
"error.unable_to_create_api_key": "تعذر إنشاء مفتاح API هذا.",
"error.unable_to_create_category": "تعذر إنشاء هذه الفئة.",
"error.unable_to_create_user": "تعذر إنشاء هذا المستخدم.",
"error.unable_to_detect_rssbridge": "تعذر اكتشاف المصدر باستخدام RSS-Bridge: %v.",
"error.unable_to_parse_feed": "تعذر تحليل هذا المصدر: %v.",
"error.unable_to_update_category": "تعذر تحديث هذه الفئة.",
"error.unable_to_update_feed": "تعذر تحديث هذا المصدر.",
"error.unable_to_update_user": "تعذر تحديث هذا المستخدم.",
"error.unlink_account_without_password": "يجب عليك تحديد كلمة مرور وإلا لن تتمكن من تسجيل الدخول مرة أخرى.",
"error.user_already_exists": "هذا المستخدم موجود بالفعل.",
"error.user_mandatory_fields": "اسم المستخدم إلزامي.",
"form.api_key.label.description": "تسمية مفتاح API",
"form.category.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
"form.category.label.title": "العنوان",
"form.feed.fieldset.general": "عام",
"form.feed.fieldset.integration": "خدمات الطرف الثالث",
"form.feed.fieldset.network_settings": "إعدادات الشبكة",
"form.feed.fieldset.rules": "قواعد",
"form.feed.label.allow_self_signed_certificates": "السماح بالشهادات الموقعة ذاتياً أو غير الصالحة",
"form.feed.label.apprise_service_urls": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
"form.feed.label.block_filter_entry_rules": "قواعد حظر المقالات",
"form.feed.label.blocklist_rules": "مرشحات الحظر المعتمدة على Regex",
"form.feed.label.category": "الفئة",
"form.feed.label.cookie": "تعيين ملفات تعريف الارتباط (Cookies)",
"form.feed.label.crawler": "جلب المحتوى الأصلي",
"form.feed.label.ignore_entry_updates": "تجاهل تحديثات المقالات",
"form.feed.label.description": "الوصف",
"form.feed.label.disable_http2": "تعطيل HTTP/2 لتجنب التتبع",
"form.feed.label.disabled": "لا تقم بتحديث هذا المصدر",
"form.feed.label.feed_password": "كلمة مرور المصدر",
"form.feed.label.feed_url": "رابط المصدر",
"form.feed.label.feed_username": "اسم مستخدم المصدر",
"form.feed.label.fetch_via_proxy": "استخدم الوكيل الذي تم تكوينه على مستوى التطبيق",
"form.feed.label.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
"form.feed.label.ignore_http_cache": "تجاهل ذاكرة التخزين المؤقت لـ HTTP",
"form.feed.label.keep_filter_entry_rules": "قواعد السماح للمقالات",
"form.feed.label.keeplist_rules": "مرشحات الاحتفاظ المعتمدة على Regex",
"form.feed.label.no_media_player": "بدون مشغل الوسائط (صوت / فيديو)",
"form.feed.label.ntfy_activate": "إرسال المقالات إلى ntfy",
"form.feed.label.ntfy_default_priority": "أولوية Ntfy الافتراضية",
"form.feed.label.ntfy_high_priority": "أولوية Ntfy عالية",
"form.feed.label.ntfy_low_priority": "أولوية Ntfy منخفضة",
"form.feed.label.ntfy_max_priority": "أقصى أولوية Ntfy",
"form.feed.label.ntfy_min_priority": "أدنى أولوية Ntfy",
"form.feed.label.ntfy_priority": "أولوية Ntfy",
"form.feed.label.ntfy_topic": "موضوع Ntfy (اختياري)",
"form.feed.label.proxy_url": "رابط الوكيل (Proxy)",
"form.feed.label.pushover_activate": "إرسال المقالات إلى Pushover",
"form.feed.label.pushover_default_priority": "الأولوية الافتراضية",
"form.feed.label.pushover_high_priority": "أولوية عالية",
"form.feed.label.pushover_low_priority": "أولوية منخفضة",
"form.feed.label.pushover_max_priority": "أولوية قصوى",
"form.feed.label.pushover_min_priority": "أولوية دنيا",
"form.feed.label.pushover_priority": "أولوية رسالة Pushover",
"form.feed.label.rewrite_rules": "قواعد إعادة كتابة المحتوى",
"form.feed.label.scraper_rules": "قواعد الكاشط (Scraper)",
"form.feed.label.site_url": "رابط الموقع",
"form.feed.label.title": "العنوان",
"form.feed.label.urlrewrite_rules": "قواعد إعادة كتابة الروابط",
"form.feed.label.user_agent": "تجاوز وكيل المستخدم الافتراضي (User Agent)",
"form.feed.label.webhook_url": "تجاوز رابط الويب هوك (Webhook)",
"form.import.label.file": "ملف OPML",
"form.import.label.url": "الرابط",
"form.integration.archiveorg_activate": "إرسال المقالات إلى archive.org",
"form.integration.apprise_activate": "إرسال المقالات إلى Apprise",
"form.integration.apprise_services_url": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
"form.integration.apprise_url": "رابط Apprise API",
"form.integration.betula_activate": "حفظ المقالات في Betula",
"form.integration.betula_token": "رمز Betula",
"form.integration.betula_url": "رابط خادم Betula",
"form.integration.cubox_activate": "حفظ المقالات في Cubox",
"form.integration.cubox_api_link": "رابط Cubox API",
"form.integration.discord_activate": "إرسال المقالات إلى Discord",
"form.integration.discord_webhook_link": "رابط Discord Webhook",
"form.integration.espial_activate": "حفظ المقالات في Espial",
"form.integration.espial_api_key": "مفتاح Espial API",
"form.integration.espial_endpoint": "نقطة نهاية Espial API",
"form.integration.espial_tags": "وسوم Espial",
"form.integration.fever_activate": "تفعيل Fever API",
"form.integration.fever_endpoint": "نقطة نهاية Fever API:",
"form.integration.fever_password": "كلمة مرور Fever",
"form.integration.fever_username": "اسم مستخدم Fever",
"form.integration.googlereader_activate": "تفعيل Google Reader API",
"form.integration.googlereader_endpoint": "نقطة نهاية Google Reader API:",
"form.integration.googlereader_password": "كلمة مرور Google Reader",
"form.integration.googlereader_username": "اسم مستخدم Google Reader",
"form.integration.instapaper_activate": "حفظ المقالات في Instapaper",
"form.integration.instapaper_password": "كلمة مرور Instapaper",
"form.integration.instapaper_username": "اسم مستخدم Instapaper",
"form.integration.karakeep_activate": "حفظ المقالات في Karakeep",
"form.integration.karakeep_api_key": "مفتاح Karakeep API",
"form.integration.karakeep_url": "نقطة نهاية Karakeep API",
"form.integration.karakeep_tags": "وسوم Karakeep",
"form.integration.linkace_activate": "حفظ المقالات في LinkAce",
"form.integration.linkace_api_key": "مفتاح LinkAce API",
"form.integration.linkace_check_disabled": "تعطيل فحص الرابط",
"form.integration.linkace_endpoint": "نقطة نهاية LinkAce API",
"form.integration.linkace_is_private": "تحديد الرابط كخاص",
"form.integration.linkace_tags": "وسوم LinkAce",
"form.integration.linkding_activate": "حفظ المقالات في Linkding",
"form.integration.linkding_api_key": "مفتاح Linkding API",
"form.integration.linkding_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
"form.integration.linkding_endpoint": "نقطة نهاية Linkding API",
"form.integration.linkding_tags": "وسوم Linkding",
"form.integration.linktaco_activate": "حفظ المقالات في LinkTaco",
"form.integration.linktaco_api_token": "رمز LinkTaco API",
"form.integration.linktaco_api_token_hint": "احصل على رمز الوصول الشخصي الخاص بك في",
"form.integration.linktaco_org_slug": "Slug المؤسسة",
"form.integration.linktaco_tags": "الوسوم (بحد أقصى 10، مفصولة بفواصل)",
"form.integration.linktaco_tags_hint": "حد أقصى 10 وسوم، مفصولة بفواصل",
"form.integration.linktaco_visibility": "الرؤية",
"form.integration.linktaco_visibility_public": "عام",
"form.integration.linktaco_visibility_private": "خاص",
"form.integration.linktaco_visibility_hint": "تتطلب رؤية خاصة حساب LinkTaco مدفوع",
"form.integration.linkwarden_activate": "حفظ المقالات في Linkwarden",
"form.integration.linkwarden_api_key": "مفتاح Linkwarden API",
"form.integration.linkwarden_endpoint": "رابط Linkwarden الأساسي",
"form.integration.linkwarden_collection_id": "معرف مجموعة Linkwarden",
"form.integration.matrix_bot_activate": "إرسال مقالات جديدة إلى Matrix",
"form.integration.matrix_bot_chat_id": "معرف غرفة Matrix",
"form.integration.matrix_bot_password": "كلمة مرور مستخدم Matrix",
"form.integration.matrix_bot_url": "رابط خادم Matrix",
"form.integration.matrix_bot_user": "اسم المستخدم في Matrix",
"form.integration.notion_activate": "حفظ المقالات في Notion",
"form.integration.notion_page_id": "معرف صفحة Notion",
"form.integration.notion_token": "رمز Notion السري",
"form.integration.ntfy_activate": "إرسال المقالات إلى ntfy",
"form.integration.ntfy_api_token": "رمز Ntfy API (اختياري)",
"form.integration.ntfy_icon_url": "رابط أيقونة Ntfy (اختياري)",
"form.integration.ntfy_internal_links": "استخدام روابط داخلية عند النقر (اختياري)",
"form.integration.ntfy_password": "كلمة مرور Ntfy (اختياري)",
"form.integration.ntfy_topic": "موضوع Ntfy (يستخدم الافتراضي إذا لم يُحدد في المصدر)",
"form.integration.ntfy_url": "رابط Ntfy (اختياري، الافتراضي هو ntfy.sh)",
"form.integration.ntfy_username": "اسم مستخدم Ntfy (اختياري)",
"form.integration.nunux_keeper_activate": "حفظ المقالات في Nunux Keeper",
"form.integration.nunux_keeper_api_key": "مفتاح Nunux Keeper API",
"form.integration.nunux_keeper_endpoint": "نقطة نهاية Nunux Keeper API",
"form.integration.omnivore_activate": "حفظ المقالات في Omnivore",
"form.integration.omnivore_api_key": "مفتاح Omnivore API",
"form.integration.omnivore_url": "نقطة نهاية Omnivore API",
"form.integration.pinboard_activate": "حفظ المقالات في Pinboard",
"form.integration.pinboard_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
"form.integration.pinboard_tags": "وسوم Pinboard",
"form.integration.pinboard_token": "رمز Pinboard API",
"form.integration.pushover_activate": "إرسال المقالات إلى Pushover",
"form.integration.pushover_device": "جهاز Pushover (اختياري)",
"form.integration.pushover_prefix": "بادئة رابط Pushover (اختياري)",
"form.integration.pushover_token": "رمز API لتطبيق Pushover",
"form.integration.pushover_user": "مفتاح مستخدم Pushover",
"form.integration.raindrop_activate": "حفظ المقالات في Raindrop",
"form.integration.raindrop_collection_id": "معرف المجموعة",
"form.integration.raindrop_tags": "الوسوم (مفصولة بفواصل)",
"form.integration.raindrop_token": "رمز (تجريبي)",
"form.integration.readeck_activate": "حفظ المقالات في readeck",
"form.integration.readeck_api_key": "مفتاح Readeck API",
"form.integration.readeck_endpoint": "رابط Readeck",
"form.integration.readeck_labels": "تسميات Readeck",
"form.integration.readeck_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
"form.integration.readeck_push_activate": "إرسال المقالات الجديدة تلقائياً إلى Readeck",
"form.integration.readwise_activate": "حفظ المقالات في Readwise Reader",
"form.integration.readwise_api_key": "رمز الوصول لـ Readwise Reader",
"form.integration.readwise_api_key_link": "احصل على رمز الوصول لـ Readwise الخاص بك",
"form.integration.rssbridge_activate": "تحقق من RSS-Bridge عند إضافة الاشتراكات",
"form.integration.rssbridge_token": "رمز مصادقة RSS-Bridge",
"form.integration.rssbridge_url": "رابط خادم RSS-Bridge",
"form.integration.shaarli_activate": "حفظ المقالات في Shaarli",
"form.integration.shaarli_api_secret": "سر Shaarli API",
"form.integration.shaarli_endpoint": "رابط Shaarli",
"form.integration.shiori_activate": "حفظ المقالات في Shiori",
"form.integration.shiori_endpoint": "نقطة نهاية Shiori API",
"form.integration.shiori_password": "كلمة مرور Shiori",
"form.integration.shiori_username": "اسم مستخدم Shiori",
"form.integration.slack_activate": "إرسال المقالات إلى Slack",
"form.integration.slack_webhook_link": "رابط Slack Webhook",
"form.integration.telegram_bot_activate": "إرسال المقالات الجديدة إلى دردشة Telegram",
"form.integration.telegram_bot_disable_buttons": "تعطيل الأزرار",
"form.integration.telegram_bot_disable_notification": "تعطيل الإشعارات",
"form.integration.telegram_bot_disable_web_page_preview": "تعطيل معاينة الرابط",
"form.integration.telegram_bot_token": "رمز البوت (Token)",
"form.integration.telegram_chat_id": "معرف الدردشة",
"form.integration.telegram_topic_id": "معرف الموضوع (Topic ID)",
"form.integration.wallabag_activate": "حفظ المقالات في Wallabag",
"form.integration.wallabag_client_id": "معرف عميل Wallabag",
"form.integration.wallabag_client_secret": "سر عميل Wallabag",
"form.integration.wallabag_endpoint": "رابط Wallabag الأساسي",
"form.integration.wallabag_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
"form.integration.wallabag_password": "كلمة مرور Wallabag",
"form.integration.wallabag_username": "اسم مستخدم Wallabag",
"form.integration.wallabag_tags": "وسوم Wallabag",
"form.integration.webhook_activate": "تفعيل Webhooks",
"form.integration.webhook_secret": "سر Webhooks",
"form.integration.webhook_url": "رابط Webhook الافتراضي",
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "قراءة المقالات عن طريق فتح الروابط الخارجية",
"form.prefs.label.categories_sorting_order": "فرز الفئات",
"form.prefs.label.cjk_reading_speed": "سرعة القراءة للغات الصينية والكورية واليابانية (حرف في الدقيقة)",
"form.prefs.label.custom_css": "CSS مخصص",
"form.prefs.label.custom_js": "JavaScript مخصص",
"form.prefs.label.default_home_page": "الصفحة الرئيسية الافتراضية",
"form.prefs.label.default_reading_speed": "سرعة القراءة للغات الأخرى (كلمة في الدقيقة)",
"form.prefs.label.display_mode": "وضع العرض (Progressive Web App - PWA)",
"form.prefs.label.entries_per_page": "عدد المقالات في الصفحة",
"form.prefs.label.entry_order": "عمود فرز المقالات",
"form.prefs.label.entry_sorting": "فرز المقالات",
"form.prefs.label.entry_swipe": "تفعيل التمرير للمقالات على الشاشات التي تعمل باللمس",
"form.prefs.label.external_font_hosts": "مضيفو الخطوط الخارجية",
"form.prefs.label.gesture_nav": "إيماءة للتنقل بين المقالات",
"form.prefs.label.keyboard_shortcuts": "تفعيل اختصارات لوحة المفاتيح",
"form.prefs.label.language": "اللغة",
"form.prefs.label.mark_read_manually": "تحديد المقالات كمقروءة يدوياً",
"form.prefs.label.mark_read_on_media_completion": "حدد كمقروء فقط عندما يصل تشغيل الصوت/الفيديو إلى 90% من الاكتمال",
"form.prefs.label.mark_read_on_view": "تحديد المقالات تلقائياً كمقروءة عند عرضها",
"form.prefs.label.mark_read_on_view_or_media_completion": "حدد المقالات كمقروءة عند عرضها. بالنسبة للصوت/الفيديو، حدد كمقروء عند اكتمال 90%",
"form.prefs.label.media_playback_rate": "سرعة تشغيل الصوت/فيديو",
"form.prefs.label.open_external_links_in_new_tab": "فتح الروابط الخارجية في تبويب جديد (يضيف target=\"_blank\" للروابط)",
"form.prefs.label.show_reading_time": "إظهار الوقت المقدر للقراءة للمقالات",
"form.prefs.label.theme": "السمة",
"form.prefs.label.timezone": "المنطقة الزمنية",
"form.prefs.select.alphabetical": "أبجدي",
"form.prefs.select.browser": "المتصفح",
"form.prefs.select.created_time": "وقت إنشاء المقال",
"form.prefs.select.fullscreen": "ملء الشاشة",
"form.prefs.select.minimal_ui": "الحد الأدنى",
"form.prefs.select.none": "بدون",
"form.prefs.select.older_first": "المقالات القديمة أولاً",
"form.prefs.select.publish_time": "وقت نشر المقال",
"form.prefs.select.recent_first": "المقالات الحديثة أولاً",
"form.prefs.select.standalone": "مستقل",
"form.prefs.select.swipe": "تمرير سريع",
"form.prefs.select.tap": "نقر مزدوج",
"form.prefs.select.unread_count": "عدد غير المقروءة",
"form.submit.loading": "جارٍ التحميل...",
"form.submit.saving": "جارٍ الحفظ...",
"form.user.label.admin": "مدير",
"form.user.label.confirmation": "تأكيد كلمة المرور",
"form.user.label.password": "كلمة المرور",
"form.user.label.username": "اسم المستخدم",
"menu.about": "حول",
"menu.add_feed": "إضافة مصدر",
"menu.add_user": "إضافة مستخدم",
"menu.api_keys": "مفاتيح API",
"menu.categories": "الفئات",
"menu.create_api_key": "إنشاء مفتاح API جديد",
"menu.create_category": "إنشاء فئة",
"menu.edit_category": "تعديل",
"menu.edit_feed": "تعديل",
"menu.export": "تصدير",
"menu.feed_entries": "المقالات",
"menu.feeds": "المصادر",
"menu.flush_history": "مسح السجل",
"menu.history": "السجل",
"menu.home_page": "الصفحة الرئيسية",
"menu.import": "استيراد",
"menu.integrations": "خدمات مرتبطة",
"menu.logout": "تسجيل الخروج",
"menu.mark_all_as_read": "تحديد الكل كمقروء",
"menu.mark_page_as_read": "تحديد هذه الصفحة كمقروءة",
"menu.preferences": "التفضيلات",
"menu.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
"menu.refresh_feed": "تحديث",
"menu.search": "بحث",
"menu.sessions": "الجلسات",
"menu.settings": "الإعدادات",
"menu.shared_entries": "المقالات المشاركة",
"menu.show_all_entries": "إظهار كل المقالات",
"menu.show_only_starred_entries": "إظهار المقالات المفضلة فقط",
"menu.show_only_unread_entries": "إظهار المقالات غير المقروءة فقط",
"menu.starred": "المفضلة",
"menu.title": "القائمة",
"menu.unread": "غير مقروء",
"menu.users": "المستخدمون",
"page.about.author": "المؤلف:",
"page.about.build_date": "تاريخ البناء:",
"page.about.credits": "شكر وتقدير",
"page.about.db_usage": "حجم قاعدة البيانات:",
"page.about.git_commit": "التزام Git:",
"page.about.global_config_options": "خيارات التكوين العامة",
"page.about.go_version": "إصدار Go:",
"page.about.license": "الرخصة:",
"page.about.postgres_version": "إصدار Postgres:",
"page.about.title": "حول",
"page.about.version": "الإصدار:",
"page.add_feed.choose_feed": "اختر مصدراً",
"page.add_feed.label.url": "الرابط",
"page.add_feed.legend.advanced_options": "خيارات متقدمة",
"page.add_feed.no_category": "لا توجد فئة. يجب أن يكون لديك فئة واحدة على الأقل.",
"page.add_feed.submit": "البحث عن مصدر",
"page.add_feed.title": "مصدر جديد",
"page.api_keys.never_used": "لم يُستخدم أبداً",
"page.api_keys.table.actions": "الإجراءات",
"page.api_keys.table.created_at": "تاريخ الإنشاء",
"page.api_keys.table.description": "الوصف",
"page.api_keys.table.last_used_at": "آخر استخدام",
"page.api_keys.table.token": "الرمز",
"page.api_keys.title": "مفاتيح API",
"page.categories.entries": "المقالات",
"page.categories.feed_count": [
"لا يوجد مصادر.",
"يوجد مصدر واحد.",
"يوجد مصدران.",
"يوجد %d مصادر.",
"يوجد %d مصدراً.",
"يوجد %d مصدراً."
],
"page.categories.feeds": "المصادر",
"page.categories.no_feed": "لا يوجد مصدر.",
"page.categories.title": "الفئات",
"page.categories_count": [
"%d فئة",
"فئة واحدة",
"فئتان",
"%d فئات",
"%d فئة",
"%d فئة"
],
"page.category_label": "الفئة: %s",
"page.edit_category.title": "تعديل الفئة: %s",
"page.edit_feed.etag_header": "رأس ETag:",
"page.edit_feed.last_check": "آخر فحص:",
"page.edit_feed.last_modified_header": "رأس LastModified:",
"page.edit_feed.last_parsing_error": "آخر خطأ تحليل",
"page.edit_feed.no_header": "لا يوجد",
"page.edit_feed.title": "تعديل المصدر: %s",
"page.edit_user.title": "تعديل المستخدم: %s",
"page.entry.attachments": "مرفقات",
"page.feeds.error_count": [
"%d خطأ",
"خطأ واحد",
"خطآن",
"%d أخطاء",
"%d خطأً",
"%d خطأً"
],
"page.feeds.last_check": "آخر فحص:",
"page.feeds.next_check": "الفحص التالي:",
"page.feeds.read_counter": "عدد المقالات المقروءة",
"page.feeds.title": "المصادر",
"page.footer.elevator": "العودة للأعلى",
"page.history.title": "السجل",
"page.import.title": "استيراد",
"page.integration.bookmarklet": "أداة الإشارة المرجعية (Bookmarklet)",
"page.integration.bookmarklet.help": "يتيح لك هذا الرابط الخاص الاشتراك في أي موقع ويب مباشرةً باستخدام إشارة مرجعية في متصفح الويب الخاص بك.",
"page.integration.bookmarklet.instructions": "اسحب وأفلت هذا الرابط إلى إشاراتك المرجعية.",
"page.integration.bookmarklet.name": "إضافة إلى Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "نقطة نهاية API",
"page.integration.miniflux_api_password": "كلمة المرور",
"page.integration.miniflux_api_password_value": "كلمة مرور حسابك",
"page.integration.miniflux_api_username": "اسم المستخدم",
"page.integrations.title": "خدمات مرتبطة",
"page.keyboard_shortcuts.close_modal": "إغلاق النافذة المنبثقة",
"page.keyboard_shortcuts.download_content": "تحميل المحتوى الأصلي",
"page.keyboard_shortcuts.go_to_bottom_item": "الذهاب إلى آخر عنصر",
"page.keyboard_shortcuts.go_to_categories": "الذهاب إلى الفئات",
"page.keyboard_shortcuts.go_to_feed": "الذهاب إلى المصدر",
"page.keyboard_shortcuts.go_to_feeds": "الذهاب إلى المصادر",
"page.keyboard_shortcuts.go_to_history": "الذهاب إلى السجل",
"page.keyboard_shortcuts.go_to_next_item": "الذهاب إلى العنصر التالي",
"page.keyboard_shortcuts.go_to_next_page": "الذهاب إلى الصفحة التالية",
"page.keyboard_shortcuts.go_to_previous_item": "الذهاب إلى العنصر السابق",
"page.keyboard_shortcuts.go_to_previous_page": "الذهاب إلى الصفحة السابقة",
"page.keyboard_shortcuts.go_to_search": "التركيز على نموذج البحث",
"page.keyboard_shortcuts.go_to_settings": "الذهاب إلى الإعدادات",
"page.keyboard_shortcuts.go_to_starred": "الذهاب إلى المفضلة",
"page.keyboard_shortcuts.go_to_top_item": "الذهاب إلى أعلى عنصر",
"page.keyboard_shortcuts.go_to_unread": "الذهاب إلى غير المقروءة",
"page.keyboard_shortcuts.mark_page_as_read": "تحديد الصفحة الحالية كمقروءة",
"page.keyboard_shortcuts.open_comments": "فتح رابط التعليقات",
"page.keyboard_shortcuts.open_comments_same_window": "فتح رابط التعليقات في التبويب الحالي",
"page.keyboard_shortcuts.open_item": "فتح العنصر المحدد",
"page.keyboard_shortcuts.open_original": "فتح الرابط الأصلي",
"page.keyboard_shortcuts.open_original_same_window": "فتح الرابط الأصلي في التبويب الحالي",
"page.keyboard_shortcuts.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
"page.keyboard_shortcuts.remove_feed": "حذف هذا المصدر",
"page.keyboard_shortcuts.save_article": "حفظ المقال",
"page.keyboard_shortcuts.scroll_item_to_top": "تمرير العنصر إلى الأعلى",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح",
"page.keyboard_shortcuts.subtitle.actions": "الإجراءات",
"page.keyboard_shortcuts.subtitle.items": "التنقل بين العناصر",
"page.keyboard_shortcuts.subtitle.pages": "التنقل بين الصفحات",
"page.keyboard_shortcuts.subtitle.sections": "التنقل بين الأقسام",
"page.keyboard_shortcuts.title": "اختصارات لوحة المفاتيح",
"page.keyboard_shortcuts.toggle_star_status": "تبديل المفضلة",
"page.keyboard_shortcuts.toggle_entry_attachments": "تبديل فتح/إغلاق مرفقات المقال",
"page.keyboard_shortcuts.toggle_read_status_next": "تبديل مقروء/غير مقروء، التركيز على التالي",
"page.keyboard_shortcuts.toggle_read_status_prev": "تبديل مقروء/غير مقروء، التركيز على السابق",
"page.login.google_signin": "تسجيل الدخول باستخدام Google",
"page.login.oidc_signin": "تسجيل الدخول باستخدام %s",
"page.login.title": "تسجيل الدخول",
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
"page.new_api_key.title": "مفتاح API جديد",
"page.new_category.title": "فئة جديدة",
"page.new_user.title": "مستخدم جديد",
"page.offline.message": "أنت غير متصل بالإنترنت",
"page.offline.refresh_page": "حاول تحديث الصفحة",
"page.offline.title": "وضع عدم الاتصال",
"page.read_entry_count": [
"%d مقال مقروء",
"مقال واحد مقروء",
"مقالان مقروءان",
"%d مقالات مقروءة",
"%d مقالاً مقروءاً",
"%d مقالاً مقروءاً"
],
"page.search.title": "نتائج البحث",
"page.sessions.table.actions": "الإجراءات",
"page.sessions.table.current_session": "الجلسة الحالية",
"page.sessions.table.date": "التاريخ",
"page.sessions.table.ip": "عنوان IP",
"page.sessions.table.user_agent": "وكيل المستخدم (User Agent)",
"page.sessions.title": "الجلسات",
"page.settings.link_google_account": "ربط حسابي في Google",
"page.settings.link_oidc_account": "ربط حسابي في %s",
"page.settings.title": "الإعدادات",
"page.settings.unlink_google_account": "فك ارتباط حسابي في Google",
"page.settings.unlink_oidc_account": "فك ارتباط حسابي في %s",
"page.settings.webauthn.actions": "الإجراءات",
"page.settings.webauthn.added_on": "أضيف في",
"page.settings.webauthn.delete": [
"إزالة %d مفتاح مرور",
"إزالة مفتاح مرور واحد",
"إزالة مفتاحي مرور",
"إزالة %d مفاتيح مرور",
"إزالة %d مفتاح مرور",
"إزالة %d مفتاح مرور"
],
"page.settings.webauthn.last_seen_on": "آخر استخدام",
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
"page.settings.webauthn.passkeys": "مفاتيح المرور",
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
"page.shared_entries.title": "المقالات المشاركة",
"page.shared_entries_count": [
"%d مقال مشترك",
"مقال واحد مشترك",
"مقالان مشتركان",
"%d مقالات مشتركة",
"%d مقالاً مشتركاً",
"%d مقالاً مشتركاً"
],
"page.starred.title": "المفضلة",
"page.starred_entry_count": [
"%d مقال مفضل",
"مقال واحد مفضل",
"مقالان مفضلان",
"%d مقالات مفضلة",
"%d مقالاً مفضلاً",
"%d مقالاً مفضلاً"
],
"page.total_entry_count": [
"%d مقال في الإجمالي",
"مقال واحد في الإجمالي",
"مقالان في الإجمالي",
"%d مقالات في الإجمالي",
"%d مقالاً في الإجمالي",
"%d مقالاً في الإجمالي"
],
"page.unread.title": "غير المقروءة",
"page.unread_entry_count": [
"%d مقال غير مقروء",
"مقال واحد غير مقروء",
"مقالان غير مقروءين",
"%d مقالات غير مقروءة",
"%d مقالاً غير مقروء",
"%d مقالاً غير مقروء"
],
"page.users.actions": "الإجراءات",
"page.users.admin.no": "لا",
"page.users.admin.yes": "نعم",
"page.users.is_admin": "مدير",
"page.users.last_login": "آخر دخول",
"page.users.never_logged": "أبداً",
"page.users.title": "المستخدمون",
"page.users.username": "اسم المستخدم",
"page.webauthn_rename.title": "إعادة تسمية مفتاح المرور",
"pagination.first": "الأول",
"pagination.last": "الأخير",
"pagination.next": "التالي",
"pagination.previous": "السابق",
"search.label": "بحث",
"search.placeholder": "بحث...",
"search.submit": "بحث",
"skip_to_content": "تخطي إلى المحتوى",
"time_elapsed.days": [
"منذ %d يوم",
"منذ يوم واحد",
"منذ يومين",
"منذ %d أيام",
"منذ %d يوماً",
"منذ %d يوماً"
],
"time_elapsed.hours": [
"منذ %d ساعة",
"منذ ساعة واحدة",
"منذ ساعتين",
"منذ %d ساعات",
"منذ %d ساعة",
"منذ %d ساعة"
],
"time_elapsed.minutes": [
"منذ %d دقيقة",
"منذ دقيقة واحدة",
"منذ دقيقتين",
"منذ %d دقائق",
"منذ %d دقيقة",
"منذ %d دقيقة"
],
"time_elapsed.months": [
"منذ %d شهر",
"منذ شهر واحد",
"منذ شهرين",
"منذ %d أشهر",
"منذ %d شهراً",
"منذ %d شهراً"
],
"time_elapsed.not_yet": "ليس بعد",
"time_elapsed.now": "الآن",
"time_elapsed.weeks": [
"منذ %d أسبوع",
"منذ أسبوع واحد",
"منذ أسبوعين",
"منذ %d أسابيع",
"منذ %d أسبوعاً",
"منذ %d أسبوعاً"
],
"time_elapsed.years": [
"منذ %d سنة",
"منذ سنة واحدة",
"منذ سنتين",
"منذ %d سنوات",
"منذ %d سنة",
"منذ %d سنة"
],
"time_elapsed.yesterday": "أمس",
"tooltip.keyboard_shortcuts": "اختصار لوحة المفاتيح: %s",
"tooltip.logged_user": "تم تسجيل الدخول باسم %s"
}
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Beschreibung",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Κατηγορία",
"form.feed.label.cookie": "Ορισμός Cookies",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Category",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disabled": "Do not refresh this feed",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descripción",
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
"form.feed.label.disabled": "No actualice este feed",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Aseta evästeet",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Kuvaus",
"form.feed.label.disable_http2": "Poista HTTP/2 käytöstä sormenjälkien välttämiseksi",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Catégorie",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Désactiver HTTP/2",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
+635
View File
@@ -0,0 +1,635 @@
{
"action.cancel": "cancelar",
"action.download": "Descargar",
"action.edit": "Editar",
"action.home_screen": "Engadir á pantalla de inicio",
"action.import": "Importar",
"action.login": "Acceso",
"action.or": "ou",
"action.remove": "Retirar",
"action.remove_feed": "Retirar esta canle",
"action.save": "Gardar",
"action.subscribe": "Dar de baixa",
"action.update": "Actualizar",
"alert.account_linked": "Conectouse a túa conta externa!",
"alert.account_unlinked": "Desconectouse a túa conta externa!",
"alert.background_feed_refresh": "Estanse actualizando en segundo plano todas as canles. Podes continuar usando Miniflux mentras se realiza a actualización.",
"alert.feed_error": "Hai un problema con esta canle.",
"alert.no_starred": "Non hai artigos con estrela.",
"alert.no_category": "Non hai categorías.",
"alert.no_category_entry": "Non hai artigos nesta categoría.",
"alert.no_feed": "Non engadiches ningunha canle.",
"alert.no_feed_entry": "Non hai artigos para esta canle.",
"alert.no_feed_in_category": "Non hai canles nesta categoría.",
"alert.no_history": "Por agora non hai historial.",
"alert.no_search_result": "Non hai resultados para esta busca.",
"alert.no_shared_entry": "Non hai artigos compartidos.",
"alert.no_tag_entry": "Non hai artigos con esta etiqueta.",
"alert.no_unread_entry": "Non hai artigos sen ler.",
"alert.no_user": "Es a única conta usuaria.",
"alert.prefs_saved": "Gardáronse as preferencias!",
"alert.too_many_feeds_refresh": [
"Intentaches demasiadas actualizacións da canle. Agarda %d minuto antes de volver intentalo.",
"Intentaches demasiadas actualizacións da canle. Agarda %d minutos antes de volver intentalo."
],
"confirm.loading": "En proceso…",
"confirm.no": "non",
"confirm.question": "Tes certeza?",
"confirm.question.refresh": "Tes certeza de querer forzar a actualización?",
"confirm.yes": "si",
"enclosure_media_controls.seek": "Avanzar:",
"enclosure_media_controls.seek.title": "Avanzar %s segundos",
"enclosure_media_controls.speed": "Velocidade:",
"enclosure_media_controls.speed.faster": "Máis rápido",
"enclosure_media_controls.speed.faster.title": "Máis rápido %sx",
"enclosure_media_controls.speed.reset": "Restablecer",
"enclosure_media_controls.speed.reset.title": "Restablecer velocidade a 1x",
"enclosure_media_controls.speed.slower": "Máis lento",
"enclosure_media_controls.speed.slower.title": "Máis lento %sx",
"entry.starred.toast.off": "Sen estrela",
"entry.starred.toast.on": "Con estrela",
"entry.starred.toggle.off": "Retirar estrela",
"entry.starred.toggle.on": "Pór estrela",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.estimated_reading_time": [
"%d minuto de lectura",
"%d minutos de lectura"
],
"entry.external_link.label": "Ligazón externa",
"entry.save.completed": "Feito!",
"entry.save.label": "Gardar",
"entry.save.title": "Gardar este artigo",
"entry.save.toast.completed": "Gardouse o artigo",
"entry.scraper.completed": "Feito!",
"entry.scraper.label": "Descargar",
"entry.scraper.title": "Obter contido orixinal",
"entry.share.label": "Compartir",
"entry.share.title": "Compartir este artigo",
"entry.shared_entry.label": "Compartir",
"entry.shared_entry.title": "Abrir ligazón pública",
"entry.state.loading": "Cargando…",
"entry.state.saving": "Gardando…",
"entry.status.mark_as_read": "Marcar como lido",
"entry.status.mark_as_unread": "Marcar como non lido",
"entry.status.title": "Cambiar estado do artigo",
"entry.status.toast.read": "Marcado como lido",
"entry.status.toast.unread": "Marcado como non lido",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar %d etiqueta máis",
"Mostrar %d etiquetas máis"
],
"entry.unshare.label": "Non compartir",
"error.api_key_already_exists": "Xa existe esta clave da API.",
"error.bad_credentials": "Credenciais incorrectas.",
"error.category_already_exists": "Xa existe a categoría.",
"error.category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
"error.database_error": "Erro na base de datos: %v.",
"error.different_passwords": "Os contrasinais non coinciden.",
"error.duplicate_fever_username": "Xa hai alguén con ese identificador en Fever!",
"error.duplicate_googlereader_username": "Xa hai alguén con ese identificador en Google Reader!",
"error.linktaco_missing_required_fields": "Requírese LinkTaco API Token e Organization Slug",
"error.duplicate_linked_account": "Xa hai alguén asociado con este provedor!",
"error.duplicated_feed": "Xa existe a canle.",
"error.empty_file": "O ficheiro está baleiro.",
"error.entries_per_page_invalid": "O número de artigos por páxina non é válido.",
"error.feed_already_exists": "Xa existe a canle.",
"error.feed_category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
"error.feed_format_not_detected": "Non se puido detectar o formato da canle: %v.",
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueo non é válida.",
"error.feed_invalid_keeplist_rule": "A regra da lista a manter non é válida.",
"error.feed_mandatory_fields": "É obrigatorio engadir un URL para a categoría.",
"error.feed_not_found": "A canle non existe ou non pertence a esta usuaria.",
"error.feed_title_not_empty": "O título da canle non pode quedar baleiro.",
"error.feed_url_not_empty": "O URL da canle non pode quedar baleiro.",
"error.fields_mandatory": "Son obrigatorios todos os campos.",
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
"error.http_client_error": "Erro HTTP no cliente: %v.",
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
"error.http_gateway_timeout": "O sitio web non está dispoñible debido a un erro de caducidade na pasarela. O problema non está en Miniflux. Volve a intentalo máis tarde.",
"error.http_internal_server_error": "O sitio web non está dispoñible debido a un fallo no servidor. O problema non está en Miniflux. Volve a intentalo máis tarde.",
"error.http_not_authorized": "Non hai autorización para acceder a este sitio web. Podería deberse a unhas credenciais non válidas.",
"error.http_resource_not_found": "Non se atopa o recurso solicitado. Por favor, comproba o URL.",
"error.http_response_too_large": "A resposta HTTP é demasiado grande. Podes aumentar o límite da resposta HTTP nos axustes xerais (require reinicio do servidor).",
"error.http_service_unavailable": "O sitio web non está dispoñible neste momento debido a un erro interno do servidor. O problema non está en Miniflux, inténtao máis tarde.",
"error.http_too_many_requests": "Miniflux xerou demasiadas peticións a este servidor. Inténtao máis tarde ou cambia a configuración da aplicación.",
"error.http_unexpected_status_code": "O sitio web non está dispoñible neste momento debido a un código de estado HTTP non agardado: %d. O problema non está en Miniflux, inténtao máis tarde.",
"error.invalid_categories_sorting_order": "Orde por categorías non válido.",
"error.invalid_default_home_page": "Páxina de inicio predeterminada non válida!",
"error.invalid_display_mode": "Modo para mostrar a aplicación web non válido.",
"error.invalid_entry_direction": "Dirección da entrada non válida.",
"error.invalid_entry_order": "Orde da entrada non válida.",
"error.invalid_feed_proxy_url": "URL do mandatario non válido.",
"error.invalid_feed_url": "URL da canle non válido.",
"error.invalid_gesture_nav": "Xesto de navegación non válido.",
"error.invalid_language": "Idioma non válido.",
"error.invalid_site_url": "URL da web non válido.",
"error.invalid_theme": "Decorado non válido.",
"error.invalid_timezone": "Zona horaria non válida.",
"error.network_operation": "Miniflux non pode acadar esta web por mor dun erro na rede: %v.",
"error.network_timeout": "Esta web é demasiado lenta e caducou a petición: %v",
"error.password_min_length": "O contrasinal ten que ter 6 caracteres polo menos.",
"error.proxy_url_not_empty": "O URL do mandatario non pode quedar baleiro.",
"error.settings_block_rule_fieldname_invalid": "Regra do Bloque non válida: á regra #%d fáltalle un nome de campo válido (Opcións: %s)",
"error.settings_block_rule_invalid_regex": "Regra do Bloque non válida: o patrón da regra #%d non é unha expresión regex válida",
"error.settings_block_rule_regex_required": "Regra do Bloque non válida: non se proporcionou o patrón da regra #%d",
"error.settings_block_rule_separator_required": "Regra do Bloque non válida:: o patrón da regra #%d require estar separado por un '='",
"error.settings_invalid_domain_list": "Lista de dominios non válida. Proporciona unha lista de dominios separados por espazos.",
"error.settings_keep_rule_fieldname_invalid": "Regra para Manter non válida: a regra #%d non ten un nome de campo válido (Opcións: %s)",
"error.settings_keep_rule_invalid_regex": "Regra para Manter non válida: o patrón da regra #%d non é unha expresión regex válida",
"error.settings_keep_rule_regex_required": "Regra para Manter non válida: non se proporcionou o patrón para #%d",
"error.settings_keep_rule_separator_required": "Regra para Manter non válida: o patrón da regra #%d ten que estar separado por un '='",
"error.settings_mandatory_fields": "O identificador, decorado, idioma e zona horaria son campos obrigatorios.",
"error.settings_media_playback_rate_range": "A velocidade de reprodución está fóra do rango admitido",
"error.settings_reading_speed_is_positive": "A velocidade de lectura ten que ser un número enteiro positivo.",
"error.site_url_not_empty": "O URL da web non pode estar baleiro.",
"error.subscription_not_found": "Non se atopou ningunha canle.",
"error.title_required": "O título é obrigatorio.",
"error.tls_error": "Erro TLS: %q. Podes desactivar a verificación TLS nos axustes da canle se queres.",
"error.unable_to_create_api_key": "Non se puido crear a clave da API.",
"error.unable_to_create_category": "Non se puido crear a categoría.",
"error.unable_to_create_user": "Non se puido crear a conta.",
"error.unable_to_detect_rssbridge": "Non se detectou a canle a usar RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Non se puido procesar a canle: %v.",
"error.unable_to_update_category": "Non se puido actualizar a categoría.",
"error.unable_to_update_feed": "Non se puido actualizar a canle.",
"error.unable_to_update_user": "Non se puido actualizar a usuaria.",
"error.unlink_account_without_password": "Tes que crear un contrasinal, se non non poderás volver acceder.",
"error.user_already_exists": "Xa existe esta usuaria.",
"error.user_mandatory_fields": "O identificador é obrigatorio.",
"form.api_key.label.description": "Etiqueta da Clave da API",
"form.category.hide_globally": "Ocultar entradas na lista global de non lidos",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "Xeral",
"form.feed.fieldset.integration": "Servizos de Terceiras Partes",
"form.feed.fieldset.network_settings": "Axustes da rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Establecer rastros",
"form.feed.label.crawler": "Obter contido orixinal",
"form.feed.label.description": "Descrición",
"form.feed.label.disable_http2": "Desactivar HTTP/2 para evitar «fingerprinting»",
"form.feed.label.disabled": "Non actualizar esta canle",
"form.feed.label.feed_password": "Contrasinal para a canle",
"form.feed.label.feed_url": "URL da canle",
"form.feed.label.feed_username": "Identificador para a canle",
"form.feed.label.fetch_via_proxy": "Usar o mandatario configurado a nivel da aplicación",
"form.feed.label.hide_globally": "Ocultar entradas na lista global de non lidos",
"form.feed.label.ignore_entry_updates": "Ignorar actualizacións da entrada",
"form.feed.label.ignore_http_cache": "Ignorar memoria tobo HTTP",
"form.feed.label.keep_filter_entry_rules": "Regra para Entradas permitidas",
"form.feed.label.keeplist_rules": "Filtros para Manter baseados en RegEx",
"form.feed.label.no_media_player": "Sen reprodutor (son/vídeo)",
"form.feed.label.ntfy_activate": "Enviar novidades a Ntfy",
"form.feed.label.ntfy_default_priority": "Prioridade predeterminada Ntfy",
"form.feed.label.ntfy_high_priority": "Alta prioridade Ntfy",
"form.feed.label.ntfy_low_priority": "Baix prioridade Ntfy",
"form.feed.label.ntfy_max_priority": "Prioridade máx. Ntfy",
"form.feed.label.ntfy_min_priority": "Prioridade mín. Ntfy",
"form.feed.label.ntfy_priority": "Prioridade en Ntfy",
"form.feed.label.ntfy_topic": "Tema en Ntfy (optativo)",
"form.feed.label.proxy_url": "URL do mandatario",
"form.feed.label.pushover_activate": "Enviar novidades a Pushover",
"form.feed.label.pushover_default_priority": "Prioridade predeterminada",
"form.feed.label.pushover_high_priority": "Alta prioridade",
"form.feed.label.pushover_low_priority": "Baixa prioridade",
"form.feed.label.pushover_max_priority": "Prioridade máx.",
"form.feed.label.pushover_min_priority": "Prioridade mín.",
"form.feed.label.pushover_priority": "Prioridade da mensaxe Pushover",
"form.feed.label.rewrite_rules": "Regras de Reescritura do contido",
"form.feed.label.scraper_rules": "Regras ao obter contido",
"form.feed.label.site_url": "URL do sitio",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Regras de rescritura URL",
"form.feed.label.user_agent": "Sobrescribir User Agent predeterminado",
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "Ficheiro OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar entradas a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
"form.integration.apprise_url": "URL de Apprise API",
"form.integration.betula_activate": "Gardar entradas en Betula",
"form.integration.betula_token": "Token de Betula",
"form.integration.betula_url": "URL do servidor Betula",
"form.integration.cubox_activate": "Gardar entradas en Cubox",
"form.integration.cubox_api_link": "Ligazón a Cubox API",
"form.integration.discord_activate": "Enviar entradas a Discord",
"form.integration.discord_webhook_link": "Ligazón a Discord Webhook",
"form.integration.espial_activate": "Enviar entradas a Espial",
"form.integration.espial_api_key": "Clave de Espial API",
"form.integration.espial_endpoint": "Acceso na Espial API",
"form.integration.espial_tags": "Etiquetas Espial",
"form.integration.fever_activate": "Activar Fever API",
"form.integration.fever_endpoint": "Punto de acceso da Fever API:",
"form.integration.fever_password": "Contrasinal Fever",
"form.integration.fever_username": "Identificador Fever",
"form.integration.googlereader_activate": "Activar API Google Reader",
"form.integration.googlereader_endpoint": "Punto de acceso de Google Reader API:",
"form.integration.googlereader_password": "Contrasinal Google Reader",
"form.integration.googlereader_username": "Identificador Google Reader",
"form.integration.instapaper_activate": "Gardar entradas en Instapaper",
"form.integration.instapaper_password": "Contrasinal Instapaper",
"form.integration.instapaper_username": "Identificador Instapaper",
"form.integration.karakeep_activate": "Gardar entradas en Karakeep",
"form.integration.karakeep_api_key": "Clave de Karakeep API",
"form.integration.karakeep_url": "Punto de acceso da Karakeep API",
"form.integration.karakeep_tags": "Etiquetas Karakeep",
"form.integration.linkace_activate": "Gardar entradas en LinkAce",
"form.integration.linkace_api_key": "Clave de LinkAce API",
"form.integration.linkace_check_disabled": "Desactivar comprobación de ligazóns",
"form.integration.linkace_endpoint": "Punto de acceso da LinkAce API",
"form.integration.linkace_is_private": "Marcar ligazón como privada",
"form.integration.linkace_tags": "Etiquetas LinkAce",
"form.integration.linkding_activate": "Gardar entradas en Linkding",
"form.integration.linkding_api_key": "Clave da Linkding API",
"form.integration.linkding_bookmark": "Marcar marcador como non lido",
"form.integration.linkding_endpoint": "Punto de acceso da Linkding API",
"form.integration.linkding_tags": "Etiquetas Linkding",
"form.integration.linktaco_activate": "Gardar entradas en LinkTaco",
"form.integration.linktaco_api_token": "Token da API de LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtén o token de acceso persoal en",
"form.integration.linktaco_org_slug": "Sobrenome da Organización",
"form.integration.linktaco_tags": "Etiquetas (máx 10, separadas por comas)",
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
"form.integration.linktaco_visibility": "Visibilidade",
"form.integration.linktaco_visibility_public": "Pública",
"form.integration.linktaco_visibility_private": "Privada",
"form.integration.linktaco_visibility_hint": "A visibilidade privada require unha conta de pago de LinkTaco",
"form.integration.linkwarden_activate": "Gardar entradas en Linkwarden",
"form.integration.linkwarden_api_key": "Clave da Linkwarden API",
"form.integration.linkwarden_endpoint": "URL Base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID da colección Linkwarden",
"form.integration.matrix_bot_activate": "Enviar entradas a Matrix",
"form.integration.matrix_bot_chat_id": "ID da Sala Matrix",
"form.integration.matrix_bot_password": "Contrasinal da usuaria Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_user": "Identificador en Matrix",
"form.integration.notion_activate": "Gardar entradas en Notion",
"form.integration.notion_page_id": "ID da páxina Notion",
"form.integration.notion_token": "Token secreto para Notion",
"form.integration.ntfy_activate": "Enviar entradas a ntfy",
"form.integration.ntfy_api_token": "Token da Ntfy API (optativo)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optativo)",
"form.integration.ntfy_internal_links": "Usar ligazóns internas ao premer (optativo)",
"form.integration.ntfy_password": "Contrasinal Ntfy (optativo)",
"form.integration.ntfy_topic": "Tema en Ntfy (úsase por defecto se non o establece a canle)",
"form.integration.ntfy_url": "Ntfy URL (optativo, predeterminado ntfy.sh)",
"form.integration.ntfy_username": "Contrasinal Ntfy (optativo)",
"form.integration.nunux_keeper_activate": "Gardar entradas en Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave da Nunux Keeper API",
"form.integration.nunux_keeper_endpoint": "Punto de acceso Nunux Keeper API",
"form.integration.omnivore_activate": "Gardar entradas en Omnivore",
"form.integration.omnivore_api_key": "Clave da Omnivore API",
"form.integration.omnivore_url": "Punto de acceso de Omnivore API",
"form.integration.pinboard_activate": "Gardar entradas en Pinboard",
"form.integration.pinboard_bookmark": "Marcar marcador como non lido",
"form.integration.pinboard_tags": "Etiquetas Pinboard",
"form.integration.pinboard_token": "Token da Pinboard API",
"form.integration.pushover_activate": "Enviar entradas a Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (optativo)",
"form.integration.pushover_prefix": "Prefixo do URL Pushover (optativo)",
"form.integration.pushover_token": "Token da aplicación na Pushover API",
"form.integration.pushover_user": "Clave da usuaria Pushover",
"form.integration.raindrop_activate": "Gardar entradas en Raindrop",
"form.integration.raindrop_collection_id": "ID da colección",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "Token (de proba)",
"form.integration.readeck_activate": "Gardar entradas en Readeck",
"form.integration.readeck_api_key": "Clave da Readeck API",
"form.integration.readeck_endpoint": "URL de Readeck",
"form.integration.readeck_labels": "Etiquetas Readeck",
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obter o token de acceso a Readwise",
"form.integration.rssbridge_activate": "Comprobar RSS-Bridge ao engadir subscricións",
"form.integration.rssbridge_token": "Token de autenticación RSS-Bridge",
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
"form.integration.shaarli_activate": "Gardar artigos en Shaarli",
"form.integration.shaarli_api_secret": "Clave secreta da API Shaarli",
"form.integration.shaarli_endpoint": "Punto de acceso Shaarli",
"form.integration.shiori_activate": "Gardar artigos en Shiori",
"form.integration.shiori_endpoint": "Punto de acceso Shiori API",
"form.integration.shiori_password": "Contrasinal Shiori",
"form.integration.shiori_username": "Identificador Shiori",
"form.integration.slack_activate": "Enviar entradas a Slack",
"form.integration.slack_webhook_link": "Ligzón de Slack Webhook",
"form.integration.telegram_bot_activate": "Enviar novas entradas a parola Telegram",
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
"form.integration.telegram_bot_token": "Toke do Bot",
"form.integration.telegram_chat_id": "ID da parola",
"form.integration.telegram_topic_id": "ID do tema",
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.wallabag_password": "Contrasinal en Wallabag",
"form.integration.wallabag_username": "Identificador en Wallabag",
"form.integration.wallabag_tags": "Etiquetas Wallabag",
"form.integration.webhook_activate": "Activar Webhooks",
"form.integration.webhook_secret": "Clave secreta Webhooks",
"form.integration.webhook_url": "URL predeterminada Webhook",
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ler artigos abringo ligazóns externas",
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS persoal",
"form.prefs.label.custom_js": "JavaScript persoal",
"form.prefs.label.default_home_page": "Páxina de inicio predeterminada",
"form.prefs.label.default_reading_speed": "Velocidade de lectura para outros idiomas (palabras por minuto)",
"form.prefs.label.display_mode": "Disposición da interface Progressive Web App (PWA)",
"form.prefs.label.entries_per_page": "Entradas por páxina",
"form.prefs.label.entry_order": "Columna para orde das entradas",
"form.prefs.label.entry_sorting": "Orde das entradas",
"form.prefs.label.entry_swipe": "Activar o desprazamento de entradas en pantallas táctiles",
"form.prefs.label.external_font_hosts": "Servidores externos de tipografías",
"form.prefs.label.gesture_nav": "Xestos para moverse entre entradas",
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
"form.prefs.label.theme": "Decorado",
"form.prefs.label.timezone": "Zona horaria",
"form.prefs.select.alphabetical": "Alfabética",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.created_time": "Hora de creación da entrada",
"form.prefs.select.fullscreen": "Pantalla completa",
"form.prefs.select.minimal_ui": "Mínima",
"form.prefs.select.none": "Ningunha",
"form.prefs.select.older_first": "Primeiro as antigas",
"form.prefs.select.publish_time": "Hora de publicación da entrada",
"form.prefs.select.recent_first": "Primeiro as recentes",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.swipe": "Desprazar",
"form.prefs.select.tap": "Doble toque",
"form.prefs.select.unread_count": "Conta de sen ler",
"form.submit.loading": "Cargando…",
"form.submit.saving": "Gardando…",
"form.user.label.admin": "Admin",
"form.user.label.confirmation": "Confirmar contrasinal",
"form.user.label.password": "Contrasinal",
"form.user.label.username": "Identificador",
"menu.about": "Sobre",
"menu.add_feed": "Engadir canle",
"menu.add_user": "Engadir usuaria",
"menu.api_keys": "Claves da API",
"menu.categories": "Categorías",
"menu.create_api_key": "Crear nova clave da API",
"menu.create_category": "Crear unha categoría",
"menu.edit_category": "Editar",
"menu.edit_feed": "Editar",
"menu.export": "Exportar",
"menu.feed_entries": "Entradas",
"menu.feeds": "Canles",
"menu.flush_history": "Eliminar historial",
"menu.history": "Historial",
"menu.home_page": "Páxina de inicio",
"menu.import": "Importar",
"menu.integrations": "Integracións",
"menu.logout": "Fechar sesión",
"menu.mark_all_as_read": "Marca todo como lido",
"menu.mark_page_as_read": "Marca esta páxina como lida",
"menu.preferences": "Preferencias",
"menu.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
"menu.refresh_feed": "Actualizar",
"menu.search": "Buscar",
"menu.sessions": "Sesións",
"menu.settings": "Axustes",
"menu.shared_entries": "Entradas compartidas",
"menu.show_all_entries": "Motrar todas as entradas",
"menu.show_only_starred_entries": "Mostrar só entradas con estrela",
"menu.show_only_unread_entries": "Mostrar só entradas sen ler",
"menu.starred": "Con estrela",
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.author": "Autoría:",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Configuración global",
"page.about.go_version": "Versión de Go:",
"page.about.license": "Licenza:",
"page.about.postgres_version": "Versión de Postgres:",
"page.about.title": "Sobre",
"page.about.version": "Versión:",
"page.add_feed.choose_feed": "Elixe unha canle",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opcións avanzadas",
"page.add_feed.no_category": "Non hai categoría. Tes que ter polo menos unha categoría.",
"page.add_feed.submit": "Atopa unha canle",
"page.add_feed.title": "Nova canle",
"page.api_keys.never_used": "Nunca utilizado",
"page.api_keys.table.actions": "Accións",
"page.api_keys.table.created_at": "Data de creación",
"page.api_keys.table.description": "Descrición",
"page.api_keys.table.last_used_at": "Último uso",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Claves da API",
"page.categories.entries": "Entradas",
"page.categories.feed_count": [
"Hai %d canle.",
"Hai %d canles."
],
"page.categories.feeds": "Canles",
"page.categories.no_feed": "No hai canle.",
"page.categories.title": "Categorías",
"page.categories_count": [
"%d categoría",
"%d categorías"
],
"page.category_label": "Categoría: %s",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_feed.etag_header": "Cabeceira ETag:",
"page.edit_feed.last_check": "Última comprobación:",
"page.edit_feed.last_modified_header": "Cabeceira LastModified:",
"page.edit_feed.last_parsing_error": "Erro Last Parsing",
"page.edit_feed.no_header": "Ningún",
"page.edit_feed.title": "Editar canle: %s",
"page.edit_user.title": "Editar usuaria: %s",
"page.entry.attachments": "Anexos",
"page.feeds.error_count": [
"%d erro",
"%d erros"
],
"page.feeds.last_check": "Última comprobación:",
"page.feeds.next_check": "Próxima comprobación:",
"page.feeds.read_counter": "Número de entradas lidas",
"page.feeds.title": "Canles",
"page.footer.elevator": "Volver arriba",
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Esta é unha ligazón especial que che permite subscribirte a unha web directamente usando un marcador no teu navegador.",
"page.integration.bookmarklet.instructions": "Arrastra e solta esta ligazón nos teus marcadores.",
"page.integration.bookmarklet.name": "Engadir a Miniflux",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Punto de acceso da API",
"page.integration.miniflux_api_password": "Contrasinal",
"page.integration.miniflux_api_password_value": "Contrasinal da túa conta",
"page.integration.miniflux_api_username": "Identificador",
"page.integrations.title": "Integracións",
"page.keyboard_shortcuts.close_modal": "Fechar diálogo modal",
"page.keyboard_shortcuts.download_content": "Descargar contido orixinal",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir ao elemento de abaixo de todo",
"page.keyboard_shortcuts.go_to_categories": "Ir a categorías",
"page.keyboard_shortcuts.go_to_feed": "Ir á canle",
"page.keyboard_shortcuts.go_to_feeds": "Ir ás canles",
"page.keyboard_shortcuts.go_to_history": "Ir ao historial",
"page.keyboard_shortcuts.go_to_next_item": "Ir ao seguinte elemento",
"page.keyboard_shortcuts.go_to_next_page": "Ir á páxina seguinte",
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao elemento anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Ir á páxina anterior",
"page.keyboard_shortcuts.go_to_search": "Pór o foco no formulario de busca",
"page.keyboard_shortcuts.go_to_settings": "Ir aos axustes",
"page.keyboard_shortcuts.go_to_starred": "Ir a artigos con estrela",
"page.keyboard_shortcuts.go_to_top_item": "Ir ao elemento de arriba de todo",
"page.keyboard_shortcuts.go_to_unread": "Ir aos non lidos",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar páxina actual como lida",
"page.keyboard_shortcuts.open_comments": "Abrir ligazón de comentarios",
"page.keyboard_shortcuts.open_comments_same_window": "Abrir ligazón de comentarios na pestana actual",
"page.keyboard_shortcuts.open_item": "Abrir elemento seleccionado",
"page.keyboard_shortcuts.open_original": "Abrir ligazón orixinal",
"page.keyboard_shortcuts.open_original_same_window": "Abrir ligazón orixinal na pestana actual",
"page.keyboard_shortcuts.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
"page.keyboard_shortcuts.remove_feed": "Retirar esta canle",
"page.keyboard_shortcuts.save_article": "Gardar entrada",
"page.keyboard_shortcuts.scroll_item_to_top": "Desprazar o elemento arriba de todo",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atallos do teclado",
"page.keyboard_shortcuts.subtitle.actions": "Accións",
"page.keyboard_shortcuts.subtitle.items": "Moverse polos elementos",
"page.keyboard_shortcuts.subtitle.pages": "Moverse polas páxinas",
"page.keyboard_shortcuts.subtitle.sections": "Moverse polas seccións",
"page.keyboard_shortcuts.title": "Atallos do teclado",
"page.keyboard_shortcuts.toggle_star_status": "Pór/quitar estrela",
"page.keyboard_shortcuts.toggle_entry_attachments": "Cambiar abrir/fechar anexos da entrada",
"page.keyboard_shortcuts.toggle_read_status_next": "Cambiar lido/non lido, foco na seguinte",
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambiar lido/non lido, foco na anterior",
"page.login.google_signin": "Acceder con Google",
"page.login.oidc_signin": "Acceder con %s",
"page.login.title": "Acceder",
"page.login.webauthn_login": "Acceso con clave de paso",
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha clave de seguridade. Non se require isto se estás a usar unha «Passkey» (credenciais descubribles).",
"page.new_api_key.title": "Nova clave da API",
"page.new_category.title": "Nova Categoría",
"page.new_user.title": "Nova Usuaria",
"page.offline.message": "Non tes conexión",
"page.offline.refresh_page": "Intenta actualizar a páxina",
"page.offline.title": "Modo sen conexión",
"page.read_entry_count": [
"%d entrada lida",
"%d entradas lidas"
],
"page.search.title": "Resultados da busca",
"page.sessions.table.actions": "Accións",
"page.sessions.table.current_session": "Sesión actual",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Enderezo IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Sesións",
"page.settings.link_google_account": "Ligar coa miña conta Google",
"page.settings.link_oidc_account": "Ligar coa miña conta %s",
"page.settings.title": "Axustes",
"page.settings.unlink_google_account": "Desligar da miña conta Google",
"page.settings.unlink_oidc_account": "Desligar da miña conta %s",
"page.settings.webauthn.actions": "Accións",
"page.settings.webauthn.added_on": "Engadida o",
"page.settings.webauthn.delete": [
"Retirar %d passkey",
"Retirar %d passkeys"
],
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da Passkey",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Rexistrar Passkey",
"page.settings.webauthn.register.error": "Non se puido rexistrar Passkey",
"page.shared_entries.title": "Entradas compartidas",
"page.shared_entries_count": [
"%d entrada compartida",
"%d entradas compartidas"
],
"page.starred.title": "Con estrela",
"page.starred_entry_count": [
"%d entrada con estrela",
"%d entradas con estrela"
],
"page.total_entry_count": [
"%d entrada en total",
"%d entradas en total"
],
"page.unread.title": "Sen ler",
"page.unread_entry_count": [
"%d entrada sen ler",
"%d entradas sen ler"
],
"page.users.actions": "Accións",
"page.users.admin.no": "Non",
"page.users.admin.yes": "Si",
"page.users.is_admin": "Administración",
"page.users.last_login": "Último acceso",
"page.users.never_logged": "Nunca",
"page.users.title": "Usuarias",
"page.users.username": "Identificador",
"page.webauthn_rename.title": "Cambiar nome a Passkey",
"pagination.first": "Primeiro",
"pagination.last": "Último",
"pagination.next": "Seguinte",
"pagination.previous": "Anterior",
"search.label": "Buscar",
"search.placeholder": "Buscar…",
"search.submit": "Buscar",
"skip_to_content": "Ir ao contido",
"time_elapsed.days": [
"hai %d día",
"hai %d días"
],
"time_elapsed.hours": [
"hai %d hora",
"hai %d horas"
],
"time_elapsed.minutes": [
"hai %d minuto",
"hai %d minutos"
],
"time_elapsed.months": [
"hai %d mes",
"hai %d meses"
],
"time_elapsed.not_yet": "aínda non",
"time_elapsed.now": "xusto agora",
"time_elapsed.weeks": [
"hai %d semana",
"hai %d semanas"
],
"time_elapsed.years": [
"hai %d ano",
"hai %d anos"
],
"time_elapsed.yesterday": "onte",
"tooltip.keyboard_shortcuts": "Atallo do teclado: %s",
"tooltip.logged_user": "Sesión de %s"
}
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "श्रेणी",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "विवरण",
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Atur Kuki",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Deskripsi",
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descrizione",
"form.feed.label.disable_http2": "Disabilita HTTP/2 per evitare il fingerprinting",
"form.feed.label.disabled": "Non aggiornare questo feed",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "カテゴリ",
"form.feed.label.cookie": "Cookie の設定",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "説明",
"form.feed.label.disable_http2": "フィンガープリンティング回避のため HTTP/2 を無効化",
"form.feed.label.disabled": "このフィードを更新しない",
@@ -172,6 +172,7 @@
"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.ignore_entry_updates": "Ignore entry updates",
"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",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.crawler": "Download originele inhoud",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Omschrijving",
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
"form.feed.label.disabled": "Deze feed niet vernieuwen",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.ignore_entry_updates": "Ignoruj aktualizacje wpisów",
"form.feed.label.description": "Opis",
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descrição",
"form.feed.label.disable_http2": "Desativar HTTP/2 para evitar fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Setare Cookie-uri",
"form.feed.label.crawler": "Aduce conținutul original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"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",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Категория",
"form.feed.label.cookie": "Установить куки",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Описание",
"form.feed.label.disable_http2": "Отключить HTTP/2 для предотвращения фингерпринтинга",
"form.feed.label.disabled": "Не обновлять эту подписку",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Açıklama",
"form.feed.label.disable_http2": "Parmak izini önlemek için HTTP/2'yi devre dışı bırakın",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Категорія",
"form.feed.label.cookie": "Встановити кукі",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Опис",
"form.feed.label.disable_http2": "Вимкнути HTTP/2 для уникнення відбитків",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "分类",
"form.feed.label.cookie": "设置 Cookie",
"form.feed.label.crawler": "获取原始内容",
"form.feed.label.ignore_entry_updates": "忽略条目更新",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "禁用 HTTP/2 以避免指纹识别",
"form.feed.label.disabled": "不刷新此订阅",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "類別",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.ignore_entry_updates": "忽略條目更新",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "停用 HTTP/2 以避免指紋追蹤",
"form.feed.label.disabled": "不要更新此 Feed",
+7
View File
@@ -55,6 +55,7 @@ type Feed struct {
PushoverEnabled bool `json:"pushover_enabled"`
NtfyEnabled bool `json:"ntfy_enabled"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyPriority int `json:"ntfy_priority"`
@@ -156,6 +157,7 @@ type FeedCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
@@ -195,6 +197,7 @@ type FeedModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
@@ -260,6 +263,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Crawler = *f.Crawler
}
if f.IgnoreEntryUpdates != nil {
feed.IgnoreEntryUpdates = *f.IgnoreEntryUpdates
}
if f.UserAgent != nil {
feed.UserAgent = *f.UserAgent
}

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