Compare commits

...

185 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
Frédéric Guillot 6439d352d2 docs(man): improve miniflux.1 consistency and fix typos 2026-01-05 18:55:33 -08:00
Frédéric Guillot a33cfb2b7d feat(locale): update translations 2026-01-05 17:58:19 -08:00
Frédéric Guillot 590adb9967 refactor(googlereader): remove output param check for user-info handler
The `output` parameter seems to be optional and Miniflux will always
returns a JSON response. This change makes Miniflux more consistent with
other open source RSS readers.
2026-01-05 16:01:01 -08:00
Frédéric Guillot 9e817e646f feat(googlereader)!: remove CORS handler
The Google Reader API is not supposed to be used by web clients.

Removing CORS should not break any Google Reader client and it
reduces the attack surface.
2026-01-05 15:49:53 -08:00
Frédéric Guillot 5594586941 fix(googlereader): generated tokens should not be logged even in debug mode 2026-01-05 15:31:23 -08:00
Frédéric Guillot 5db66fc279 refactor(storage): avoid using Sprintf to update session fields 2026-01-04 20:30:09 -08:00
Frédéric Guillot 561389da69 feat: add TRUSTED_REVERSE_PROXY_NETWORKS config option
Add an IP-based allow list to prevent spoofing of HTTP headers that
should only be set by trusted reverse proxies.

Note that `TRUSTED_REVERSE_PROXY_NETWORKS` must be configured when
`AUTH_PROXY_HEADER` is used.

The following HTTP headers are taken into consideration only when the
client is an allowed reverse proxy: `X-Forwarded-For`,
`X-Forwarded-Proto` and `X-Real-Ip`.
2026-01-04 11:10:46 -08:00
jvoisin cd7d61966c refactor(database): dont' index removed entries' content
`document_vectors_idx` used to take 30M before this commit, now it's only 226
kB, after a full VACUUM. A handy command to check the size of indexes is `\di+
public.*`
2026-01-03 14:11:09 -08:00
Frédéric Guillot 29f6dc8896 feat(icon): disallow fetching icon on private networks
This change avoid possible SSRF issues and it's configurable at the instance level
2025-12-29 11:15:44 -08:00
Frédéric Guillot 6c83e8c477 feat(mediaproxy): disallow the media proxy to fetch resources on private networks
This change avoid possible SSRF issues and it's configurable at the instance level
2025-12-29 11:15:44 -08:00
jvoisin 29015903ba feat(ui): smooth pages transitions
Smooth transitions are pretty nice,
see https://htmhell.dev/adventcalendar/2024/3/
2025-12-28 19:04:54 -08:00
Frédéric Guillot 709e671168 feat(api): execute the content sanitizer when updating or importing entries 2025-12-28 17:34:10 -08:00
Julien Voisin 473c9f225e refactor(config): reuse validateChoices in validateListChoices to avoid duplication 2025-12-28 17:10:20 -08:00
Gerald Cox 90a389ac25 feat: add API endpoint to import entries into existing feed 2025-12-28 16:56:23 -08:00
Frédéric Guillot 6106546a32 test(rewrite): add additional test cases for GetRefererForURL 2025-12-21 12:23:57 -08:00
Lennard Schwarz ad82191ce1 chore: remove obsolete docker compose 'version' key 2025-12-21 11:59:01 -08:00
Lennard Schwarz 6080d56a5c fix: Update go devcontainer image to go:1-trixie 2025-12-21 11:57:59 -08:00
jvoisin 854985bcfa refactor(config): visibility reduction and assorted changes
- Don't expose struct members unnecessarily
- Don't use `fmt.Fprintf` to format strings
- Don't check if a value needs to be redacted if it's empty
2025-12-20 16:55:58 -08:00
Mateusz Jabłoński 1bf7c0bb51 feat(ui): add route for viewing individual starred entries from category starred list 2025-12-19 17:07:02 -08:00
dependabot[bot] bc409480e7 build(deps): bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 17:52:20 -08:00
Matthaiks 3cb74e152b feat(locale): update Polish translation 2025-12-14 19:54:12 -08:00
Lennard Schwarz fbc4d700d5 feat: add auto-push option to readeck integration 2025-12-13 20:28:34 -08:00
Julien Voisin 40d9965b28 feat(template): add link to the GitHub contributors page 2025-12-13 20:12:41 -08:00
Michael Kuhn 28e670d989 build: update Distroless container to Debian 13 2025-12-13 20:09:42 -08:00
dependabot[bot] dc12713be1 build(deps): bump golang.org/x/net from 0.47.0 to 0.48.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/net/compare/v0.47.0...v0.48.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-09 16:31:26 -08:00
dependabot[bot] 7bf58e7ab0 build(deps): bump library/alpine from 3.22 to 3.23 in /packaging/docker/alpine 2025-12-08 16:31:51 -08:00
dependabot[bot] c502b80fa9 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.7 to 2.24.8
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.7 to 2.24.8.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.7...v2.24.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 14:42:54 -08:00
dependabot[bot] c23a62184b build(deps): bump golang.org/x/oauth2 from 0.33.0 to 0.34.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 14:40:13 -08:00
dependabot[bot] 327119c828 build(deps): bump golang.org/x/image from 0.33.0 to 0.34.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/image/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 14:31:00 -08:00
dependabot[bot] 488a3bba5f build(deps): bump golang.org/x/crypto from 0.45.0 to 0.46.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 14:27:18 -08:00
Frédéric Guillot 7f3c6db4d8 ci: remove create event from Codeberg workflow 2025-12-08 14:24:00 -08:00
dependabot[bot] 884569fc76 build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 14:21:28 -08:00
Frédéric Guillot 0960624e85 ci: add workflow to mirror Git repo to Codeberg 2025-12-05 17:41:44 -08:00
Julien Voisin 4063ca39c9 feat(config): add new option to disable Miniflux's API 2025-12-04 17:43:08 -08:00
Matthaiks f6476db767 feat(locale): update Polish translation 2025-12-02 09:55:31 -08:00
Frédéric Guillot 142d8d7679 refactor(integration): standardize Linkwarden collection ID naming 2025-12-01 17:16:39 -08:00
Barend van der Walt 5bd5993a24 feat: add Linkwarden collection ID support 2025-12-01 16:56:10 -08:00
Frédéric Guillot 18cd544fab chore(contrib): update postgres volume path in Docker Compose sample files
Starting with Postgresql 18, the volume path has been changed to
`/var/lib/postgresql`.

See https://hub.docker.com/_/postgres/#pgdata
2025-12-01 16:12:27 -08:00
Mateusz Jabłoński 88f9f279e6 feat(finder): generate feeds for stable youtube playlists 2025-11-30 12:39:41 -08:00
Mateusz Jabłoński dc1653ba3a feat(finder): make cannonical url detection a proper step 2025-11-30 12:39:41 -08:00
Mateusz Jabłoński 3ea4aee4d6 feat(finder): enhance youtube channel parsing with default playlists 2025-11-30 12:39:41 -08:00
jvoisin f447307359 feat: add the i and small tags to the sanitizer's allowlist 2025-11-30 12:11:14 -08:00
Frédéric Guillot 4892e04e9b fix: avoid YouTube error 153 for embed iframes
Add the attribute `referrerpolicy="strict-origin-when-cross-origin"` to
YouTube iframes.

Superseded PR #3862
2025-11-28 17:09:08 -08:00
dependabot[bot] 579b1efc68 build(deps): bump github.com/coreos/go-oidc/v3 from 3.16.0 to 3.17.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.16.0 to 3.17.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.16.0...v3.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-28 11:43:35 -08:00
dependabot[bot] a16aa10fed build(deps): bump golang.org/x/crypto from 0.44.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-28 11:35:13 -08:00
dependabot[bot] ea9adc8978 build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-28 11:34:48 -08:00
dependabot[bot] fd7c886997 build(deps): bump github.com/PuerkitoBio/goquery from 1.10.3 to 1.11.0
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.3 to 1.11.0.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.3...v1.11.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-28 11:34:18 -08:00
Jiacheng b3a63dbd86 fix(api): rewrite entry content URLs with media proxy in fetchContent endpoint 2025-11-11 20:41:29 -08:00
dependabot[bot] 7ac8eb96c4 build(deps): bump golang.org/x/net from 0.46.0 to 0.47.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.46.0 to 0.47.0.
- [Commits](https://github.com/golang/net/compare/v0.46.0...v0.47.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-11 20:08:16 -08:00
dependabot[bot] c43a90716b build(deps): bump golang.org/x/crypto from 0.43.0 to 0.44.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.44.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-11 19:57:07 -08:00
dependabot[bot] 0a08f4832e build(deps): bump golang.org/x/image from 0.32.0 to 0.33.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/image/compare/v0.32.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-11 19:42:05 -08:00
dependabot[bot] bc375e3c59 build(deps): bump github.com/go-webauthn/webauthn from 0.14.0 to 0.15.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.14.0 to 0.15.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.14.0...v0.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:34:22 -08:00
dependabot[bot] d3ebfd67dd build(deps): bump golang.org/x/oauth2 from 0.32.0 to 0.33.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.32.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:31:54 -08:00
dependabot[bot] c1cfd184ee build(deps): bump github.com/tdewolff/minify/v2 from 2.24.6 to 2.24.7
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.6 to 2.24.7.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.6...v2.24.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:31:06 -08:00
dependabot[bot] e580126b8d build(deps): bump golangci/golangci-lint-action from 8 to 9
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 19:28:48 -08:00
Frédéric Guillot 86fca5d060 test(urlcleaner): add test case to cover Matomo tracking URL 2025-11-06 18:58:39 -08:00
jvoisin c365118cf7 feat(urlcleaner): add a bunch of parameters
Taken from a cursory look at https://rules2.clearurls.xyz/data.minify.json
2025-11-06 18:58:39 -08:00
dependabot[bot] 6f2d10c436 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.5 to 2.24.6
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.5 to 2.24.6.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.5...v2.24.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 17:04:08 -08:00
Julien Voisin 093004b724 refactor(rewrite): avoid string concatenation in a loop (perfsprint linter fix) 2025-11-04 16:59:46 -08:00
dependabot[bot] d2c783b06c build(deps): bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 19:53:47 -07:00
Frédéric Guillot 7e2dd3afe6 fix: avoid YouTube error 153 video player configuration error 2025-10-26 14:11:56 -07:00
jvoisin 4ace959667 refactor(js): use replace instead of remove+add. 2025-10-26 13:06:01 -07:00
Frédéric Guillot 14f31954d1 fix(ci): enhance CodeQL workflow with language matrix and dynamic analysis category 2025-10-25 17:55:16 -07:00
Frédéric Guillot 907daf78bf fix(ci): specify version for RPM package for schedule and pull_request events 2025-10-25 17:44:59 -07:00
Frédéric Guillot cf6386b471 fix(ci): GitHub Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-25 17:22:17 -07:00
Frédéric Guillot 76df99f3a3 fix: only relative path should allowed for redirectURL parameter
Protocol-relative URLs like `//example.org` should not be allowed.
2025-10-25 17:17:33 -07:00
jvoisin 57e3eaecd9 refactor(js): minor regex simplification
There is no need to match on the whole title, use two groups, then merge the
whole thing together, when we can simply search-and-replace only the matching
value.
2025-10-25 16:45:46 -07:00
dependabot[bot] 21d3a5a61c build(deps): bump github.com/tdewolff/minify/v2 from 2.24.4 to 2.24.5
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.4 to 2.24.5.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.4...v2.24.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 18:10:02 -07:00
222 changed files with 8769 additions and 3555 deletions
+4 -2
View File
@@ -9,7 +9,9 @@
],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"customizations": {
"vscode": {
@@ -28,4 +30,4 @@
]
}
}
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go:1.23
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
volumes:
- ..:/workspace:cached
command: sleep infinity
@@ -11,10 +10,10 @@ services:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:15
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
hostname: postgres
environment:
POSTGRES_DB: miniflux2
+5 -2
View File
@@ -1,4 +1,6 @@
name: Build Binaries
permissions:
contents: read
on:
workflow_dispatch:
push:
@@ -7,10 +9,11 @@ on:
jobs:
build:
name: Build
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Golang
uses: actions/setup-go@v6
with:
@@ -21,7 +24,7 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: binaries
path: miniflux-*
+26
View File
@@ -0,0 +1,26 @@
name: Mirror to Codeberg
on:
push:
branches: [ main ]
delete:
workflow_dispatch:
jobs:
mirror:
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Mirror to Codeberg
env:
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
run: |
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
git push --force --prune codeberg \
"refs/heads/*:refs/heads/*" \
"refs/tags/*:refs/tags/*"
+12 -2
View File
@@ -9,6 +9,7 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
@@ -16,12 +17,14 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '45 22 * * 3'
workflow_dispatch:
jobs:
analyze:
name: Analyze
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
@@ -30,20 +33,27 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: actions/setup-go@v6
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+12 -11
View File
@@ -13,17 +13,18 @@ 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:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
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
@@ -38,13 +39,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
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@v4
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.deb"
@@ -64,13 +65,13 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
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
+11 -10
View File
@@ -12,18 +12,19 @@ on:
jobs:
docker-images:
name: Docker Images
if: github.repository_owner == 'miniflux'
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- 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: .
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: stable
- uses: golangci/golangci-lint-action@v8
- uses: golangci/golangci-lint-action@v9
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,7 +38,7 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
+6 -5
View File
@@ -11,17 +11,18 @@ on:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
run: make rpm VERSION=2.2.x_dev
- name: List generated files
run: ls -l *.rpm
build-package-manually:
@@ -29,13 +30,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.rpm"
@@ -46,7 +47,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
+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 .+)"
)
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
@@ -44,7 +44,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
-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
------------
+42 -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 {
@@ -717,6 +719,32 @@ func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChange
return f, nil
}
// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()
var response struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}
return response.ID, nil
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
ctx, cancel := withDefaultTimeout()
@@ -860,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()
@@ -885,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()
@@ -910,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()
@@ -976,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)
}
+1 -1
View File
@@ -26,7 +26,7 @@ services:
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+1 -1
View File
@@ -31,7 +31,7 @@ services:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+1 -1
View File
@@ -43,7 +43,7 @@ services:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+19 -21
View File
@@ -1,27 +1,28 @@
module miniflux.app/v2
// +heroku goVersion go1.24
// +heroku goVersion go1.26
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/go-webauthn/webauthn v0.14.0
github.com/coreos/go-oidc/v3 v3.17.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.4
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
golang.org/x/term v0.36.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.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // 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.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.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.4 // 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.37.0 // indirect
golang.org/x/text v0.30.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
+36 -34
View File
@@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@@ -8,8 +8,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -17,17 +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-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
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.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/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=
@@ -40,10 +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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.4 h1:pQyr6eWDa+RXtAoZg+6wurh0jB9ojqw/qc5LlU7/z6c=
github.com/tdewolff/minify/v2 v2.24.4/go.mod h1:iD9Qn7/brhKY9d0KLKMkZrqS8/bqxSxRKruBi7V6m+w=
github.com/tdewolff/parse/v2 v2.8.4 h1:A6slgBLGGDPBMGA28KQZfHpaKffuNvhOe7zSag+x/rw=
github.com/tdewolff/parse/v2 v2.8.4/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
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=
+1
View File
@@ -62,6 +62,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
+68 -15
View File
@@ -14,6 +14,7 @@ import (
"testing"
miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
@@ -579,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)
@@ -608,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)
@@ -637,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 {
@@ -661,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)
@@ -690,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)
@@ -720,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)
@@ -1089,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)
@@ -1108,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)
@@ -1127,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" {
@@ -1611,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)
@@ -1652,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 {
@@ -1684,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 {
@@ -2717,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)
@@ -2932,3 +2933,55 @@ func TestFlushHistoryEndpoint(t *testing.T) {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}
func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)
// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)
payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}
// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}
// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
+112 -5
View File
@@ -11,6 +11,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
@@ -18,6 +19,7 @@ import (
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
@@ -274,6 +276,11 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
@@ -287,6 +294,110 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
json.Created(w, r, entry)
}
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("feed does not exist"))
return
}
var importRequest entryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if importRequest.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}
if importRequest.Status == "" {
importRequest.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
json.BadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = importRequest.URL
entry.CommentsURL = importRequest.CommentsURL
entry.Author = importRequest.Author
entry.Tags = importRequest.Tags
if importRequest.PublishedAt > 0 {
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if importRequest.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = importRequest.Title
}
hashInput := importRequest.ExternalID
if hashInput == "" {
hashInput = importRequest.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if importRequest.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if err != nil {
json.ServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = importRequest.Status
if importRequest.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
json.Created(w, r, entryIDResponse{ID: entry.ID})
} else {
json.OK(w, r, entryIDResponse{ID: entry.ID})
}
}
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
@@ -341,13 +452,9 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
return
}
json.OK(w, r, map[string]string{"content": entry.Content})
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"})
}
+30
View File
@@ -18,10 +18,40 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
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",
+8 -2
View File
@@ -30,9 +30,9 @@ const (
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
@@ -109,6 +109,12 @@ func Parse() {
}
}
if config.Opts.AuthProxyHeader() != "" {
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
}
}
if flagConfigDump {
fmt.Print(config.Opts)
return
+450 -411
View File
File diff suppressed because it is too large Load Diff
+87 -21
View File
@@ -3,7 +3,10 @@
package config // import "miniflux.app/v2/internal/config"
import "testing"
import (
"slices"
"testing"
)
func TestBaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1270,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()
@@ -1364,6 +1351,54 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
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")
}
}
func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1584,6 +1619,37 @@ func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
}
}
func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(defaultNetworks) != 0 {
t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
}
// Test valid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(allowedNetworks) != 2 {
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
}
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
}
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) {
configParser := NewConfigParser()
@@ -1614,8 +1680,8 @@ func TestSetLogLevelFunction(t *testing.T) {
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].RawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
if configParser.options.options["LOG_LEVEL"].rawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
// Test setting log level to warning
@@ -1623,8 +1689,8 @@ func TestSetLogLevelFunction(t *testing.T) {
if configParser.options.LogLevel() != "warning" {
t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].RawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
if configParser.options.options["LOG_LEVEL"].rawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
}
+43 -39
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@@ -51,7 +52,7 @@ func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].ParsedStringValue
baseURL := cp.options.options["BASE_URL"].parsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
parsedURL, err := url.Parse(baseURL)
@@ -64,14 +65,14 @@ func (cp *configParser) postParsing() error {
return errors.New("BASE_URL scheme must be http or https")
}
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
cp.options.options["BASE_URL"].parsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
@@ -81,16 +82,16 @@ func (cp *configParser) postParsing() error {
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
}
return nil
@@ -119,73 +120,76 @@ 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
}
// Validate the option if a validator is provided
if field.Validator != nil {
if err := field.Validator(value); err != nil {
if field.validator != nil {
if err := field.validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.ValueType {
switch field.valueType {
case stringType:
field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
field.RawValue = value
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
field.rawValue = value
case stringListType:
field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
field.RawValue = value
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
field.rawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.ParsedBoolValue = parsedValue
field.RawValue = value
field.parsedBoolValue = parsedValue
field.rawValue = value
case intType:
field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
field.RawValue = value
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
field.rawValue = value
case int64Type:
field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
field.RawValue = value
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
field.rawValue = value
case secondType:
field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
field.rawValue = value
case minuteType:
field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
field.rawValue = value
case hourType:
field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
field.rawValue = value
case dayType:
field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
field.rawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.ParsedURLValue)
parsedURL, err := parseURLValue(value, field.parsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.ParsedURLValue = parsedURL
field.RawValue = value
field.parsedURLValue = parsedURL
field.rawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.TargetKey != "" {
if targetField, ok := cp.options.options[field.TargetKey]; ok {
targetField.ParsedStringValue = secretValue
targetField.RawValue = secretValue
if field.targetKey != "" {
if targetField, ok := cp.options.options[field.targetKey]; ok {
targetField.parsedStringValue = secretValue
targetField.rawValue = secretValue
}
}
field.RawValue = value
field.rawValue = value
case bytesType:
if value != "" {
field.ParsedBytesValue = []byte(value)
field.RawValue = value
field.parsedBytesValue = []byte(value)
field.rawValue = value
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ func validateChoices(rawValue string, choices []string) error {
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if !slices.Contains(choices, value) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
+59 -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
@@ -1373,4 +1373,62 @@ var migrations = [...]func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// There is no need to keep an index on the content of deleted entries.
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
if err != nil {
return err
}
sql := `
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors)
WHERE status != 'removed';
`
_, 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)
+44 -89
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.
@@ -47,9 +48,7 @@ func Serve(router *mux.Router, store *storage.Storage) {
middleware := newMiddleware(store)
sr := router.PathPrefix("/reader/api/0").Subrouter()
sr.Use(middleware.handleCORS)
sr.Use(middleware.apiKeyAuth)
sr.Methods(http.MethodOptions)
sr.HandleFunc("/token", handler.tokenHandler).Methods(http.MethodGet).Name("Token")
sr.HandleFunc("/edit-tag", handler.editTagHandler).Methods(http.MethodPost).Name("EditTag")
sr.HandleFunc("/rename-tag", handler.renameTagHandler).Methods(http.MethodPost).Name("Rename Tag")
@@ -71,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:
@@ -91,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:
@@ -244,7 +243,6 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
slog.String("token", token),
)
w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
@@ -455,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,
})
}
@@ -587,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) {
@@ -700,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 {
@@ -742,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 {
@@ -750,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)
@@ -792,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,
},
@@ -893,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 {
@@ -936,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",
})
@@ -968,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),
})
@@ -1003,16 +1004,16 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
userInfo := userInfoResponse{UserID: strconv.FormatInt(user.ID, 10), UserName: user.Username, UserProfileID: strconv.FormatInt(user.ID, 10), UserEmail: user.Username}
json.OK(w, r, userInfo)
}
@@ -1070,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",
@@ -1105,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)
@@ -1142,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)
@@ -1181,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})
@@ -1194,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)
@@ -1234,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})
}
-13
View File
@@ -25,19 +25,6 @@ func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) handleCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
+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)
}
}
+62 -19
View File
@@ -9,19 +9,46 @@ import (
"strings"
)
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
if len(trustedNetworks) == 0 {
return false
}
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
ip := net.ParseIP(remoteIP)
if ip == nil {
return false
}
if net.ParseIP(address) != nil {
return address
for _, cidr := range trustedNetworks {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// 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"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
if net.ParseIP(address) != nil {
return address
}
}
}
}
@@ -30,19 +57,35 @@ func FindClientIP(r *http.Request) 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
}
+78 -21
View File
@@ -10,27 +10,37 @@ import (
func TestFindClientIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
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)
}
}
@@ -41,7 +51,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -50,7 +60,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -59,7 +69,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -68,7 +78,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "70.41.3.18" {
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -77,7 +87,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -87,7 +97,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.122.1" {
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -99,15 +109,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
r := &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r); ip != "@" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -119,7 +121,62 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "@", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestIsTrustedIP(t *testing.T) {
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
scenarios := []struct {
ip string
expected bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"::1", true},
{"192.168.1.1", false},
{"invalid", false},
{"@", false},
{"/tmp/miniflux.sock", false},
{"", false},
}
for _, scenario := range scenarios {
result := IsTrustedIP(scenario.ip, trustedNetworks)
if result != scenario.expected {
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
}
}
if IsTrustedIP("127.0.0.1", nil) {
t.Error("Expected false when no trusted networks are defined")
}
if IsTrustedIP("127.0.0.1", []string{}) {
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}
+13 -42
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
@@ -243,15 +241,13 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
api.Serve(subrouter, store, pool)
if config.Opts.HasAPI() {
api.Serve(subrouter, store, pool)
}
ui.Serve(subrouter, store, pool)
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 {
@@ -314,32 +310,7 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
}
remoteIP := request.FindRemoteIP(r)
if remoteIP == "@" {
// This indicates a request sent via a Unix socket, always consider these trusted.
return true
}
for _, cidr := range config.Opts.MetricsAllowedNetworks() {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
slog.Error("Metrics endpoint accessed with invalid CIDR",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
slog.String("cidr", cidr),
)
return false
}
// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
// The recommendation is to use HTTP Basic authentication.
if network.Contains(net.ParseIP(remoteIP)) {
return true
}
}
return false
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
}
func printErrorAndExit(format string, a ...any) {
+4 -2
View File
@@ -15,11 +15,13 @@ import (
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.FindClientIP(r)
remoteIP := request.FindRemoteIP(r)
isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
ctx := r.Context()
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if r.Header.Get("X-Forwarded-Proto") == "https" {
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.SetHTTPSValue(true)
}
+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)
+65 -31
View File
@@ -271,23 +271,26 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
if userIntegrations.LinkwardenEnabled {
slog.Debug("Sending entry to linkwarden",
attrs := []any{
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
}
if userIntegrations.LinkwardenCollectionID != nil {
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
}
slog.Debug("Sending entry to linkwarden", attrs...)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
userIntegrations.LinkwardenCollectionID,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Linkwarden",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
attrs = append(attrs, slog.Any("error", err))
slog.Error("Unable to send entry to Linkwarden", attrs...)
}
}
@@ -406,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 {
@@ -444,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),
@@ -540,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),
@@ -639,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,
@@ -655,30 +665,54 @@ 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),
)
}
// Push each new entry to Readeck when push is enabled
if userIntegrations.ReadeckPushEnabled {
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
for _, entry := range entries {
slog.Debug("Sending a new entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bytes"
"log/slog"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
coll := int64(12345)
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenCollectionID: &coll,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if !strings.Contains(out, `"collection_id":12345`) {
t.Fatalf("expected collection_id in logs; got: %s", out)
}
}
func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if strings.Contains(out, "collection_id") {
t.Fatalf("did not expect collection_id in logs; got: %s", out)
}
}
+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
})
}
+35 -10
View File
@@ -8,9 +8,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -18,12 +21,23 @@ import (
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
baseURL string
apiKey string
collectionID *int64
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
type linkwardenCollection struct {
ID *int64 `json:"id"`
}
type linkwardenRequest struct {
URL string `json:"url"`
Name string `json:"name"`
Collection *linkwardenCollection `json:"collection,omitempty"`
}
func NewClient(baseURL, apiKey string, collectionID *int64) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, collectionID: collectionID}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
@@ -36,10 +50,16 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(map[string]string{
"url": entryURL,
"name": entryTitle,
})
payload := linkwardenRequest{
URL: entryURL,
Name: entryTitle,
}
if c.collectionID != nil {
payload.Collection = &linkwardenCollection{ID: c.collectionID}
}
requestBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
@@ -54,15 +74,20 @@ 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)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("linkwarden: unable to read response body: %v", err)
}
if response.StatusCode >= 400 {
return fmt.Errorf("linkwarden: unable to create link: url=%s status=%d", apiEndpoint, response.StatusCode)
return fmt.Errorf("linkwarden: unable to create link: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
@@ -0,0 +1,346 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkwarden
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"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
apiKey string
collectionID *int64
entryURL string
entryTitle string
serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation without collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test Article",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article" {
t.Errorf("Expected name 'Test Article', got %v", reqName)
}
// Verify collection is not present when nil
if _, ok := req["collection"]; ok {
t.Error("Expected collection field to be omitted when collectionId is nil")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "123",
"url": "https://example.com",
"name": "Test Article",
})
},
wantErr: false,
},
{
name: "successful bookmark creation with collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: model.OptionalNumber(int64(42)),
entryURL: "https://example.com/article",
entryTitle: "Test Article With Collection",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com/article" {
t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article With Collection" {
t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
}
// Verify collection is present and correct
if collection, ok := req["collection"]; ok {
collectionMap, ok := collection.(map[string]any)
if !ok {
t.Error("Expected collection to be a map")
}
if collectionID, ok := collectionMap["id"]; ok {
// JSON numbers are float64
if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
t.Errorf("Expected collection id 42, got %v", collectionID)
}
} else {
t.Error("Expected collection to have 'id' field")
}
} else {
t.Error("Expected collection field to be present when collectionId is set")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "124",
"url": "https://example.com/article",
"name": "Test Article With Collection",
})
},
wantErr: false,
},
{
name: "missing API key",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when API key is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "server error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Internal server error"}`))
},
wantErr: true,
errContains: "unable to create link: status=500",
},
{
name: "bad request with null collection id error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
},
wantErr: true,
errContains: "unable to create link: status=400",
},
{
name: "unauthorized",
baseURL: "",
apiKey: "invalid-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
},
wantErr: true,
errContains: "unable to create link: status=401",
},
{
name: "invalid base URL",
baseURL: ":",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is invalid")
},
wantErr: true,
errContains: "invalid API endpoint",
},
{
name: "missing base URL",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "network connection error",
baseURL: "http://localhost:1", // Invalid port that should fail to connect
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called due to connection failure
t.Error("Server should not be called when connection fails")
},
wantErr: true,
errContains: "unable to send request",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
var server *httptest.Server
if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tt.serverResponse(w, r, t, tt.collectionID)
}))
defer server.Close()
}
// Use test server URL if baseURL is empty and we have a server
baseURL := tt.baseURL
if baseURL == "" && server != nil {
baseURL = server.URL
}
// Create client
client := NewClient(baseURL, tt.apiKey, tt.collectionID)
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
// Check error
if tt.wantErr {
if err == nil {
t.Error("Expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
collectionID *int64
}{
{
name: "client without collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: nil,
},
{
name: "client with collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: model.OptionalNumber(int64(123)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
if client.baseURL != tt.baseURL {
t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
}
if client.apiKey != tt.apiKey {
t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
}
if tt.collectionID == nil {
if client.collectionID != nil {
t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
}
} else {
if client.collectionID == nil {
t.Error("Expected collectionId to be set, got nil")
} else if *client.collectionID != *tt.collectionID {
t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
}
}
})
}
}
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"`
}
@@ -0,0 +1,282 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package readeck
import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
entryURL := "https://example.com/article"
entryTitle := "Example Title"
entryContent := "<p>Some HTML content</p>"
labels := "tag1,tag2"
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)
}
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)
seenLabels := []string{}
var seenURL, seenTitle, seenFeature string
var resourceHeader map[string]any
var resourceBody string
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)
}
}
}
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)
}
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",
},
}
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"
}
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
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"
}
+5 -2
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",
@@ -266,6 +267,7 @@
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
"form.integration.linkwarden_collection_id": "Linkwarden-Sammlungs-ID",
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
@@ -306,6 +308,7 @@
"form.integration.readeck_endpoint": "Readeck-URL",
"form.integration.readeck_labels": "Readeck-Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
@@ -370,10 +373,10 @@
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Zeitzone",
"form.prefs.select.alphabetical": "Alphabetisch",
"form.prefs.select.browser": "Browser",
"form.prefs.select.browser": "Systembrowser",
"form.prefs.select.created_time": "Artikel erstellt am",
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.minimal_ui": "Minimale Oberfläche",
"form.prefs.select.none": "Keine",
"form.prefs.select.older_first": "Ältere Artikel zuerst",
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
+13 -10
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": "Μη ανανέωση αυτής της ροής",
@@ -254,9 +255,9 @@
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_tags": "Ετικέτες Linkding",
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token": "Διακριτικό API LinkTaco",
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_org_slug": "Σύντομο όνομα οργανισμού",
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
"form.integration.linktaco_visibility": "Ορατότητα",
@@ -266,6 +267,7 @@
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
"form.integration.linkwarden_collection_id": "ID συλλογής Linkwarden",
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
@@ -306,6 +308,7 @@
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_labels": "Ετικέτες Readeck",
"form.integration.readeck_only_url": "Αποστολή μόνο 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",
@@ -335,7 +338,7 @@
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.wallabag_tags": "Ετικέτες Wallabag",
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
"form.integration.webhook_secret": "Μυστικό Webhooks",
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
@@ -434,7 +437,7 @@
"page.about.title": "Περί",
"page.about.version": "Έκδοση:",
"page.add_feed.choose_feed": "Επιλέξτε μια συνδρομή",
"page.add_feed.label.url": "URL",
"page.add_feed.label.url": "Διεύθυνση URL",
"page.add_feed.legend.advanced_options": "Προχωρημένες Επιλογές",
"page.add_feed.no_category": "Δεν υπάρχει κατηγορία. Πρέπει να έχετε τουλάχιστον μία κατηγορία.",
"page.add_feed.submit": "Βρείτε μια συνδρομή",
@@ -444,7 +447,7 @@
"page.api_keys.table.created_at": "Ημερομηνία Δημιουργίας",
"page.api_keys.table.description": "Περιγραφή",
"page.api_keys.table.last_used_at": "Τελευταία Χρήση",
"page.api_keys.table.token": "Token",
"page.api_keys.table.token": "Διακριτικό",
"page.api_keys.title": "Κλειδιά API",
"page.categories.entries": "Άρθρα",
"page.categories.feed_count": [
@@ -476,14 +479,14 @@
"page.feeds.next_check": "Επόμενος έλεγχος:",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.title": "Ροές",
"page.footer.elevator": "Back to top",
"page.footer.elevator": "Επιστροφή στην κορυφή",
"page.history.title": "Ιστορικό",
"page.import.title": "Εισαγωγή",
"page.integration.bookmarklet": "Bookmarklet",
"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": "API του Miniflux",
"page.integration.miniflux_api_endpoint": "Τελικό σημείο API",
"page.integration.miniflux_api_password": "Κωδικός",
"page.integration.miniflux_api_password_value": "Ο κωδικός πρόσβασης του λογαριασμού σας",
@@ -546,7 +549,7 @@
"page.sessions.table.current_session": "Τρέχουσα Συνεδρία",
"page.sessions.table.date": "Ημερομηνία",
"page.sessions.table.ip": "Διεύθυνση IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.user_agent": "Πρόγραμμα περιήγησης (User Agent)",
"page.sessions.title": "Συνεδρίες",
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "χθες",
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
}
}
+4 -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",
@@ -266,6 +267,7 @@
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.matrix_bot_password": "Password for Matrix user",
@@ -306,6 +308,7 @@
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "yesterday",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s"
}
}
+15 -12
View File
@@ -164,7 +164,7 @@
"form.api_key.label.description": "Etiqueta de clave API",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.general": "Generalidades",
"form.feed.fieldset.integration": "Servicios de terceros",
"form.feed.fieldset.network_settings": "Ajustes de red",
"form.feed.fieldset.rules": "Reglas",
@@ -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",
@@ -245,7 +246,7 @@
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
"form.integration.linkace_api_key": "Clave API de LinkAce",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_endpoint": "Extremo de la API de LinkAce",
"form.integration.linkace_is_private": "Marcar enlace como privado",
"form.integration.linkace_tags": "Etiquetas de LinkAce",
"form.integration.linkding_activate": "Enviar artículos a Linkding",
@@ -254,9 +255,9 @@
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_tags": "Etiquetas de Linkding",
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token": "Token de la API de LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_org_slug": "Slug de la 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": "Visibilidad",
@@ -266,6 +267,7 @@
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID de colección de Linkwarden",
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
@@ -277,7 +279,7 @@
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_internal_links": "Usar enlaces internos al hacer clic (opcional)",
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
"form.integration.ntfy_topic": "Tema Ntfy (por defecto, si no se establece en el feed)",
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
@@ -300,17 +302,18 @@
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
"form.integration.raindrop_collection_id": "Colección ID",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_token": "Token (prueba)",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_labels": "Etiquetas de Readeck",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.readeck_push_activate": "Enviar automáticamente nuevas entradas a Readeck",
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_token": "Token de autenticación de RSS-Bridge",
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
@@ -327,7 +330,7 @@
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
"form.integration.telegram_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de chat",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_topic_id": "ID de tema",
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
@@ -335,7 +338,7 @@
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.wallabag_tags": "Etiquetas de Wallabag",
"form.integration.webhook_activate": "Habilitar Webhooks",
"form.integration.webhook_secret": "Secreto de Webhooks",
"form.integration.webhook_url": "Defecto URL de Webhook",
@@ -426,7 +429,7 @@
"page.about.build_date": "Fecha de compilación:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Tamaño de la base de datos:",
"page.about.git_commit": "Git Commit:",
"page.about.git_commit": "Commit de Git:",
"page.about.global_config_options": "Opciones de configuración global",
"page.about.go_version": "Go versión:",
"page.about.license": "Licencia:",
@@ -476,7 +479,7 @@
"page.feeds.next_check": "Próxima verificación:",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.title": "Fuentes",
"page.footer.elevator": "Back to top",
"page.footer.elevator": "Volver arriba",
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Marcapáginas",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "ayer",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s"
}
}
+172 -169
View File
@@ -104,56 +104,56 @@
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
"error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
"error.http_client_error": "HTTP-asiakasvirhe: %v.",
"error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
"error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
"error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",
"error.http_gateway_timeout": "Sivusto ei ole nyt käytettävissä yhdyskäytävän aikakatkaisun vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_internal_server_error": "Sivusto ei ole nyt käytettävissä palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_not_authorized": "Pääsy tälle sivustolle ei ole sallittu. Käyttäjänimi tai salasana voi olla väärä.",
"error.http_resource_not_found": "Pyydettyä resurssia ei löytynyt. Tarkista URL-osoite.",
"error.http_response_too_large": "HTTP-vastaus on liian suuri. Voit kasvattaa rajan yleisasetuksissa (vaatii palvelimen uudelleenkäynnistyksen).",
"error.http_service_unavailable": "Sivusto ei ole nyt käytettävissä sisäisen palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.http_too_many_requests": "Miniflux lähetti liikaa pyyntöjä tälle sivustolle. Yritä myöhemmin uudelleen tai muuta sovelluksen asetuksia.",
"error.http_unexpected_status_code": "Sivusto ei ole nyt käytettävissä odottamattoman HTTP-tilakoodin %d vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.",
"error.invalid_categories_sorting_order": "Virheellinen kategorioiden lajittelujärjestys.",
"error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
"error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_entry_direction": "Virheellinen merkintäsuunta.",
"error.invalid_entry_order": "Virheellinen artikkelin lajittelu.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_feed_proxy_url": "Virheellinen välityspalvelimen URL.",
"error.invalid_feed_url": "Virheellinen syötteen URL-osoite.",
"error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
"error.invalid_language": "Virheellinen kieli.",
"error.invalid_site_url": "Virheellinen sivuston URL-osoite.",
"error.invalid_theme": "Virheellinen teema.",
"error.invalid_timezone": "Virheellinen aikavyöhyke.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.network_operation": "Miniflux ei tavoita tätä sivustoa verkkovirheen vuoksi: %v.",
"error.network_timeout": "Tämä sivusto on liian hidas ja pyyntö aikakatkaistiin: %v",
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "Välityspalvelimen URL ei voi olla tyhjä.",
"error.settings_block_rule_fieldname_invalid": "Virheellinen estosääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
"error.settings_block_rule_invalid_regex": "Virheellinen estosääntö: säännön #%d kuvio ei ole kelvollinen regex",
"error.settings_block_rule_regex_required": "Virheellinen estosääntö: säännöltä #%d puuttuu kuvio",
"error.settings_block_rule_separator_required": "Virheellinen estosääntö: säännön #%d kuvio tulee erottaa merkillä '='",
"error.settings_invalid_domain_list": "Virheellinen verkkotunnuslista. Anna välilyönnein eroteltu luettelo.",
"error.settings_keep_rule_fieldname_invalid": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)",
"error.settings_keep_rule_invalid_regex": "Virheellinen säilytyssääntö: säännön #%d kuvio ei ole kelvollinen regex",
"error.settings_keep_rule_regex_required": "Virheellinen säilytyssääntö: säännöltä #%d puuttuu kuvio",
"error.settings_keep_rule_separator_required": "Virheellinen säilytyssääntö: säännön #%d kuvio tulee erottaa merkillä '='",
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
"error.site_url_not_empty": "Sivuston URL-osoite ei voi olla tyhjä.",
"error.subscription_not_found": "Tilausta ei löydy.",
"error.title_required": "Otsikko on pakollinen.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS-virhe: %q. Voit halutessasi poistaa TLS-tarkistuksen syöteasetuksista.",
"error.unable_to_create_api_key": "API-avainta ei voi luoda.",
"error.unable_to_create_category": "Kategoriaa ei voi luoda.",
"error.unable_to_create_user": "Käyttäjää ei voi luoda.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "Syötettä ei voitu havaita RSS-Bridgea käyttäen: %v.",
"error.unable_to_parse_feed": "Tätä syötettä ei voitu jäsentää: %v.",
"error.unable_to_update_category": "Kategoriaa ei voi päivittää.",
"error.unable_to_update_feed": "Syötettä ei voi päivittää.",
"error.unable_to_update_user": "Käyttäjää ei voi päivittää.",
@@ -161,22 +161,23 @@
"error.user_already_exists": "Käyttäjä on jo olemassa.",
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
"form.api_key.label.description": "API Key Label",
"form.api_key.label.description": "API-avaimen nimi",
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.category.label.title": "Otsikko",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Yleiset",
"form.feed.fieldset.integration": "Kolmannen osapuolen palvelut",
"form.feed.fieldset.network_settings": "Verkkoasetukset",
"form.feed.fieldset.rules": "Säännöt",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.apprise_service_urls": "Apprise-palvelujen URL-osoitteet pilkuilla eroteltuna",
"form.feed.label.block_filter_entry_rules": "Merkinnän estosäännöt",
"form.feed.label.blocklist_rules": "Regex-pohjaiset estosuodattimet",
"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": "Disable HTTP/2 to avoid fingerprinting",
"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ä",
"form.feed.label.feed_password": "Syötteen salasana",
"form.feed.label.feed_url": "Syötteen URL-osoite",
@@ -186,43 +187,43 @@
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.keep_filter_entry_rules": "Merkinnän sallimissäännöt",
"form.feed.label.keeplist_rules": "Regex-pohjaiset säilytyssuodattimet",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.no_media_player": "Ei mediasoitinta (ääni/video)",
"form.feed.label.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
"form.feed.label.ntfy_default_priority": "Ntfy-oletusprioriteetti",
"form.feed.label.ntfy_high_priority": "Ntfy-korkea prioriteetti",
"form.feed.label.ntfy_low_priority": "Ntfy-matala prioriteetti",
"form.feed.label.ntfy_max_priority": "Ntfy-enimmäisprioriteetti",
"form.feed.label.ntfy_min_priority": "Ntfy-vähimmäisprioriteetti",
"form.feed.label.ntfy_priority": "Ntfy-prioriteetti",
"form.feed.label.ntfy_topic": "Ntfy-aihe (valinnainen)",
"form.feed.label.proxy_url": "Välityspalvelimen URL",
"form.feed.label.pushover_activate": "Lähetä merkinnät pushover.net-palveluun",
"form.feed.label.pushover_default_priority": "Pushover-oletusprioriteetti",
"form.feed.label.pushover_high_priority": "Pushover-korkea prioriteetti",
"form.feed.label.pushover_low_priority": "Pushover-matala prioriteetti",
"form.feed.label.pushover_max_priority": "Pushover-enimmäisprioriteetti",
"form.feed.label.pushover_min_priority": "Pushover-vähimmäisprioriteetti",
"form.feed.label.pushover_priority": "Pushover-viestin prioriteetti",
"form.feed.label.rewrite_rules": "Sisällön uudelleenkirjoitussäännöt",
"form.feed.label.scraper_rules": "Scraper-säännöt",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.title": "Otsikko",
"form.feed.label.urlrewrite_rules": "URL-osoitteen uudelleenkirjoitussäännöt",
"form.feed.label.user_agent": "Ohita oletuskäyttäjäagentti",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Ohita oletus-webhook-osoite",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL",
"form.import.label.url": "URL-osoite",
"form.integration.archiveorg_activate": "Työnnä merkinnät osoitteeseen archive.org",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.apprise_activate": "Lähetä merkinnät Appriseen",
"form.integration.apprise_services_url": "Pilkuilla eroteltu Apprise-palvelujen URL-lista",
"form.integration.apprise_url": "Apprise API -osoite",
"form.integration.betula_activate": "Tallenna merkinnät Betulaan",
"form.integration.betula_token": "Betula-tunnus",
"form.integration.betula_url": "Betula-palvelimen URL",
"form.integration.cubox_activate": "Tallenna merkinnät Cuboxiin",
"form.integration.cubox_api_link": "Cubox API -linkki",
"form.integration.discord_activate": "Lähetä merkinnät Discordiin",
"form.integration.discord_webhook_link": "Discord-webhook-linkki",
"form.integration.espial_activate": "Tallenna artikkelit Espialiin",
"form.integration.espial_api_key": "Espial API-avain",
"form.integration.espial_endpoint": "Espial API-päätepiste",
@@ -241,47 +242,48 @@
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
"form.integration.karakeep_api_key": "Karakeep API-avain",
"form.integration.karakeep_url": "Karakeep API-päätepiste",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.karakeep_tags": "Karakeep-tunnisteet",
"form.integration.linkace_activate": "Tallenna merkinnät LinkAceen",
"form.integration.linkace_api_key": "LinkAce API -avain",
"form.integration.linkace_check_disabled": "Poista linkkitarkistus käytöstä",
"form.integration.linkace_endpoint": "LinkAce API -päätepiste",
"form.integration.linkace_is_private": "Merkitse linkki yksityiseksi",
"form.integration.linkace_tags": "LinkAce-tunnisteet",
"form.integration.linkding_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkding_api_key": "Linkding API-avain",
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_tags": "Linkding-tunnisteet",
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
"form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
"form.integration.linktaco_api_token": "LinkTaco API -tunnus",
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunniste osoitteesta",
"form.integration.linktaco_org_slug": "Organisaation slug",
"form.integration.linktaco_tags": "Tunnisteet (enintään 10, pilkuilla eroteltu)",
"form.integration.linktaco_tags_hint": "Enintään 10 tunnistetta, pilkuilla eroteltuna",
"form.integration.linktaco_visibility": "Näkyvyys",
"form.integration.linktaco_visibility_public": "Julkinen",
"form.integration.linktaco_visibility_private": "Yksityinen",
"form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.linktaco_visibility_hint": "Yksityinen näkyvyys vaatii maksullisen LinkTaco-tilin",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkwardeniin",
"form.integration.linkwarden_api_key": "Linkwarden API -avain",
"form.integration.linkwarden_endpoint": "Linkwardenin perus-URL",
"form.integration.linkwarden_collection_id": "Linkwarden-kokoelman tunnus",
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_user": "Matrixin käyttäjätunnus",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.notion_activate": "Tallenna merkinnät Notioniin",
"form.integration.notion_page_id": "Notion-sivun tunnus",
"form.integration.notion_token": "Notion-salaisuustunnus",
"form.integration.ntfy_activate": "Lähetä merkinnät ntfy-palveluun",
"form.integration.ntfy_api_token": "Ntfy API -tunnus (valinnainen)",
"form.integration.ntfy_icon_url": "Ntfy-kuvakkeen URL (valinnainen)",
"form.integration.ntfy_internal_links": "Käytä sisäisiä linkkejä napsautettaessa (valinnainen)",
"form.integration.ntfy_password": "Ntfy-salasana (valinnainen)",
"form.integration.ntfy_topic": "Ntfy-aihe (oletus jos ei määritetty syötteessä)",
"form.integration.ntfy_url": "Ntfy-URL (valinnainen, oletus ntfy.sh)",
"form.integration.ntfy_username": "Ntfy-käyttäjätunnus (valinnainen)",
"form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
@@ -292,59 +294,60 @@
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.pinboard_tags": "Pinboard-tagit",
"form.integration.pinboard_token": "Pinboard API-tunnus",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pushover_activate": "Lähetä merkinnät Pushoveriin",
"form.integration.pushover_device": "Pushover-laite (valinnainen)",
"form.integration.pushover_prefix": "Pushover-URL:n etuliite (valinnainen)",
"form.integration.pushover_token": "Pushover-sovelluksen API-tunnus",
"form.integration.pushover_user": "Pushover-käyttäjän avain",
"form.integration.raindrop_activate": "Tallenna merkinnät Raindropiin",
"form.integration.raindrop_collection_id": "Kokoelman tunnus",
"form.integration.raindrop_tags": "Tunnisteet (pilkuilla eroteltu)",
"form.integration.raindrop_token": "(Testi) tunnus",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_api_key": "Readeck API-avain",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Readeck-tunnisteet",
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shiori_username": "Shiori Username",
"form.integration.slack_activate": "Push entries to Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.readeck_push_activate": "Lähetä uudet merkinnät automaattisesti Readeckiin",
"form.integration.readwise_activate": "Tallenna merkinnät Readwise Readeriin",
"form.integration.readwise_api_key": "Readwise Reader -pääsytunnus",
"form.integration.readwise_api_key_link": "Hanki Readwise-pääsytunnus",
"form.integration.rssbridge_activate": "Tarkista RSS-Bridge tilauksia lisättäessä",
"form.integration.rssbridge_token": "RSS-Bridge-todennustunnus",
"form.integration.rssbridge_url": "RSS-Bridge-palvelimen URL",
"form.integration.shaarli_activate": "Tallenna artikkelit Shaarliin",
"form.integration.shaarli_api_secret": "Shaarli API -salaisuus",
"form.integration.shaarli_endpoint": "Shaarli-osoite",
"form.integration.shiori_activate": "Tallenna artikkelit Shioriin",
"form.integration.shiori_endpoint": "Shiori API -päätepiste",
"form.integration.shiori_password": "Shiori-salasana",
"form.integration.shiori_username": "Shiori-käyttäjätunnus",
"form.integration.slack_activate": "Lähetä merkinnät Slackiin",
"form.integration.slack_webhook_link": "Slack-webhook-linkki",
"form.integration.telegram_bot_activate": "Lähetä uusia artikkeleita Telegram-chatiin",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_buttons": "Poista painikkeet käytöstä",
"form.integration.telegram_bot_disable_notification": "Poista ilmoitukset käytöstä",
"form.integration.telegram_bot_disable_web_page_preview": "Poista sivun esikatselu käytöstä",
"form.integration.telegram_bot_token": "Bot-tunnus",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_chat_id": "Keskustelun tunnus",
"form.integration.telegram_topic_id": "Aiheen tunnus",
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_client_id": "Wallabag-asiakastunnus",
"form.integration.wallabag_client_secret": "Wallabag-asiakassalaisuus",
"form.integration.wallabag_endpoint": "Wallabagin perus-URL-osoite",
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.wallabag_password": "Wallabag-salasana",
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.integration.wallabag_tags": "Wallabag-tunnisteet",
"form.integration.webhook_activate": "Ota webhookit käyttöön",
"form.integration.webhook_secret": "Webhookien salaisuus",
"form.integration.webhook_url": "Oletus-webhook-URL",
"form.prefs.fieldset.application_settings": "Sovellusasetukset",
"form.prefs.fieldset.authentication_settings": "Todennusasetukset",
"form.prefs.fieldset.global_feed_settings": "Syötteiden yleisasetukset",
"form.prefs.fieldset.reader_settings": "Lukija-asetukset",
"form.prefs.help.external_font_hosts": "Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Lue artikkelit avaamalla ulkoiset linkit",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
"form.prefs.label.custom_css": "Mukautettu CSS",
@@ -356,14 +359,14 @@
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
"form.prefs.label.entry_sorting": "Lajittelu",
"form.prefs.label.entry_swipe": "Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Ulkoiset fonttipalvelimet",
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
"form.prefs.label.keyboard_shortcuts": "Ota pikanäppäimet käyttöön",
"form.prefs.label.language": "Kieli",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Merkitse merkinnät luetuiksi manuaalisesti",
"form.prefs.label.mark_read_on_media_completion": "Merkitse luetuksi vasta, kun ääni/video on 90%% toistettu",
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_view_or_media_completion": "Merkitse merkinnät luetuiksi katsottaessa. Ääni/videolle merkitse 90%% toistettuna",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
@@ -419,14 +422,14 @@
"menu.show_only_starred_entries": "Näytä vain suosikit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
"menu.starred": "Suosikit",
"menu.title": "Menu",
"menu.title": "Valikko",
"menu.unread": "Lukemattomat",
"menu.users": "Käyttäjät",
"page.about.author": "Tekijä:",
"page.about.build_date": "Valmistuspäivä:",
"page.about.credits": "Kiitokset",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.db_usage": "Tietokannan koko:",
"page.about.git_commit": "Git-toimite:",
"page.about.global_config_options": "Yleiset asetukset",
"page.about.go_version": "Go-versio:",
"page.about.license": "Lisenssi:",
@@ -455,10 +458,10 @@
"page.categories.no_feed": "Ei syötettä.",
"page.categories.title": "Kategoriat",
"page.categories_count": [
"%d category",
"%d categories"
"%d kategoria",
"%d kategoriaa"
],
"page.category_label": "Category: %s",
"page.category_label": "Kategoria: %s",
"page.edit_category.title": "Muokkaa kategoria: %s",
"page.edit_feed.etag_header": "ETag-otsikko:",
"page.edit_feed.last_check": "Viimeisin tarkistus:",
@@ -473,17 +476,17 @@
"%d virhettä"
],
"page.feeds.last_check": "Viimeisin tarkistus:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Seuraava tarkistus:",
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
"page.feeds.title": "Syötteet",
"page.footer.elevator": "Back to top",
"page.footer.elevator": "Takaisin ylös",
"page.history.title": "Historia",
"page.import.title": "Tuo",
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
"page.integration.bookmarklet.help": "This special link allows you to subscribe to a website directly by using a bookmark in your web browser.",
"page.integration.bookmarklet.help": "Tämä erityinen linkki antaa sinun tilata verkkosivuston suoraan selaimen kirjanmerkillä.",
"page.integration.bookmarklet.instructions": "Vedä ja pudota tämä linkki kirjanmerkkeihisi.",
"page.integration.bookmarklet.name": "Lisää Minifluxiin",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api": "Minifluxin API",
"page.integration.miniflux_api_endpoint": "API-päätepiste",
"page.integration.miniflux_api_password": "Salasana",
"page.integration.miniflux_api_password_value": "Tilisi salasana",
@@ -522,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
"page.keyboard_shortcuts.title": "Pikanäppäimet",
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Avaa tai sulje merkinnän liitteet",
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
"page.login.google_signin": "Kirjaudu sisään Googlella",
@@ -530,7 +533,7 @@
"page.login.title": "Kirjaudu sisään",
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.",
"page.new_api_key.title": "Uusi API-avain",
"page.new_category.title": "Uusi kategoria",
"page.new_user.title": "Uusi käyttäjä",
@@ -538,8 +541,8 @@
"page.offline.refresh_page": "Yritä päivittää sivu",
"page.offline.title": "Offline-tila",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d luettu merkintä",
"%d luettua merkintää"
],
"page.search.title": "Hakutulokset",
"page.sessions.table.actions": "Toiminnot",
@@ -553,35 +556,35 @@
"page.settings.title": "Asetukset",
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "Toiminnot",
"page.settings.webauthn.added_on": "Lisätty",
"page.settings.webauthn.delete": [
"Poista %d salasana",
"Poista %d salasanaa"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.last_seen_on": "Viimeksi käytetty",
"page.settings.webauthn.passkey_name": "Passkey-nimi",
"page.settings.webauthn.passkeys": "Passkeyt",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.shared_entries.title": "Jaetut artikkelit",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d jaettu merkintä",
"%d jaettua merkintää"
],
"page.starred.title": "Suosikit",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d suosikkimerkintä",
"%d suosikkimerkintää"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"Yhteensä %d merkintä",
"Yhteensä %d merkintää"
],
"page.unread.title": "Lukemattomat",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d lukematon merkintä",
"%d lukematonta merkintää"
],
"page.users.actions": "Toiminnot",
"page.users.admin.no": "Ei",
@@ -591,7 +594,7 @@
"page.users.never_logged": "Ei koskaan",
"page.users.title": "Käyttäjät",
"page.users.username": "Käyttäjätunnus",
"page.webauthn_rename.title": "Rename Passkey",
"page.webauthn_rename.title": "Nimeä passkey uudelleen",
"pagination.first": "Ensimmäinen",
"pagination.last": "Viimeinen",
"pagination.next": "Seuraava",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "eilen",
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
}
}
+9 -6
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",
@@ -257,15 +258,16 @@
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
"form.integration.linktaco_org_slug": "Slug de l'organisation",
"form.integration.linktaco_tags": "Tags (max. 10, séparés par des virgules)",
"form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
"form.integration.linktaco_tags": "Libellés (max. 10, séparés par des virgules)",
"form.integration.linktaco_tags_hint": "Maximum 10 libellés, séparés par des virgules",
"form.integration.linktaco_visibility": "Visibilité",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_public": "Publique",
"form.integration.linktaco_visibility_private": "Privé",
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID de collection Linkwarden",
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
@@ -306,6 +308,7 @@
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_labels": "Libellés Readeck",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.readeck_push_activate": "Envoyer automatiquement les nouvelles entrées vers Readeck",
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
@@ -373,7 +376,7 @@
"form.prefs.select.browser": "Navigateur",
"form.prefs.select.created_time": "Heure de création de l'entrée",
"form.prefs.select.fullscreen": "Plein écran",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.minimal_ui": "Minimaliste",
"form.prefs.select.none": "Aucun",
"form.prefs.select.older_first": "Anciens éléments en premier",
"form.prefs.select.publish_time": "Heure de publication de l'entrée",
@@ -479,7 +482,7 @@
"page.footer.elevator": "Retour en haut",
"page.history.title": "Historique",
"page.import.title": "Importation",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet": "Signet (bookmarklet)",
"page.integration.bookmarklet.help": "Ce lien spécial vous permet de vous abonner à un site web directement en utilisant un marque page dans votre navigateur web.",
"page.integration.bookmarklet.instructions": "Glisser-déposer ce lien dans vos favoris.",
"page.integration.bookmarklet.name": "Ajouter à Miniflux",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "hier",
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
"tooltip.logged_user": "Connecté en tant que %s"
}
}
+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"
}
+160 -157
View File
@@ -109,14 +109,14 @@
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "गेटवे टाइमआउट त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_internal_server_error": "सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_not_authorized": "इस वेबसाइट तक पहुंच अधिकृत नहीं है। यह गलत उपयोगकर्ता नाम या पासवर्ड हो सकता है।",
"error.http_resource_not_found": "अनुरोधित संसाधन नहीं मिला। कृपया यूआरएल सत्यापित करें।",
"error.http_response_too_large": "HTTP प्रतिक्रिया बहुत बड़ी है। आप वैश्विक सेटिंग्स में HTTP प्रतिक्रिया आकार सीमा बढ़ा सकते हैं (सर्वर पुनःआरंभ आवश्यक)।",
"error.http_service_unavailable": "आंतरिक सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.http_too_many_requests": "मिनीफ्लक्स ने इस वेबसाइट पर बहुत अधिक अनुरोध भेजे हैं। कृपया बाद में पुनः प्रयास करें या एप्लिकेशन कॉन्फ़िगरेशन बदलें।",
"error.http_unexpected_status_code": "अप्रत्याशित HTTP स्थिति कोड %d के कारण वेबसाइट उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।",
"error.invalid_categories_sorting_order": "अमान्य श्रेणी क्रम।",
"error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
@@ -129,26 +129,26 @@
"error.invalid_site_url": "अमान्य साइट यूआरएल",
"error.invalid_theme": "अमान्य थीम.",
"error.invalid_timezone": "अमान्य समयक्षेत्र.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.network_operation": "नेटवर्क त्रुटि के कारण मिनीफ्लक्स इस वेबसाइट तक नहीं पहुँच पा रहा: %v.",
"error.network_timeout": "यह वेबसाइट बहुत धीमी है और अनुरोध का समय समाप्त हो गया: %v",
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "प्रॉक्सी यूआरएल खाली नहीं हो सकता।",
"error.settings_block_rule_fieldname_invalid": "अमान्य ब्लॉक नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)",
"error.settings_block_rule_invalid_regex": "अमान्य ब्लॉक नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है",
"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 का पैटर्न मान्य रेगेक्स नहीं है",
"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 error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS त्रुटि: %q. यदि आप चाहें तो फ़ीड सेटिंग्स में TLS सत्यापन अक्षम कर सकते हैं।",
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
@@ -164,19 +164,20 @@
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.category.label.title": "शीर्षक",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"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": "Comma separated list of Apprise service URLs",
"form.feed.label.apprise_service_urls": "Apprise सेवा URL की कॉमा से अलग सूची",
"form.feed.label.block_filter_entry_rules": "प्रविष्टि अवरोधन नियम",
"form.feed.label.blocklist_rules": "रेगेक्स-आधारित अवरोधन फिल्टर",
"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": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.feed_password": "फ़ीड पासवर्ड",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
@@ -186,43 +187,43 @@
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.keep_filter_entry_rules": "प्रविष्टि अनुमति नियम",
"form.feed.label.keeplist_rules": "रेगेक्स-आधारित रखने वाले फिल्टर",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"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": "प्रॉक्सी URL",
"form.feed.label.pushover_activate": "प्रविष्टियाँ pushover.net पर भेजें",
"form.feed.label.pushover_default_priority": "Pushover डिफ़ॉल्ट प्राथमिकता",
"form.feed.label.pushover_high_priority": "Pushover उच्च प्राथमिकता",
"form.feed.label.pushover_low_priority": "Pushover निम्न प्राथमिकता",
"form.feed.label.pushover_max_priority": "Pushover अधिकतम प्राथमिकता",
"form.feed.label.pushover_min_priority": "Pushover न्यूनतम प्राथमिकता",
"form.feed.label.pushover_priority": "Pushover संदेश प्राथमिकता",
"form.feed.label.rewrite_rules": "सामग्री पुनर्लेखन नियम",
"form.feed.label.scraper_rules": "खुरचनी नियम",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.title": "शीर्षक",
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.user_agent": "डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "वेबहुक URL को अधिलेखित करें",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.archiveorg_activate": "प्रविष्टियों को archive.org पर भेजें",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.apprise_activate": "प्रविष्टियाँ Apprise पर भेजें",
"form.integration.apprise_services_url": "Apprise सेवा URLs की कॉमा से पृथक सूची",
"form.integration.apprise_url": "Apprise API यूआरएल",
"form.integration.betula_activate": "प्रविष्टियाँ Betula में सहेजें",
"form.integration.betula_token": "Betula टोकन",
"form.integration.betula_url": "Betula सर्वर URL",
"form.integration.cubox_activate": "प्रविष्टियाँ Cubox में सहेजें",
"form.integration.cubox_api_link": "Cubox API लिंक",
"form.integration.discord_activate": "प्रविष्टियाँ Discord पर भेजें",
"form.integration.discord_webhook_link": "Discord वेबहुक लिंक",
"form.integration.espial_activate": "विषय-वस्तु को जासूसी में सहेजें",
"form.integration.espial_api_key": "जासूसी एपीआई कुंजी",
"form.integration.espial_endpoint": "जासूसी एपीआई समापन बिंदु",
@@ -238,96 +239,98 @@
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.karakeep_tags": "Karakeep Labels",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"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": "लिंक्डिन में विषयवस्तु सहेजें",
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_tags": "Linkding टैग",
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token": "LinkTaco API टोकन",
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_org_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": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
"form.integration.linkwarden_activate": "प्रविष्टियाँ Linkwarden में सहेजें",
"form.integration.linkwarden_api_key": "Linkwarden API कुंजी",
"form.integration.linkwarden_endpoint": "Linkwarden बेस URL",
"form.integration.linkwarden_collection_id": "Linkwarden संग्रह ID",
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.notion_activate": "प्रविष्टियाँ Notion में सहेजें",
"form.integration.notion_page_id": "Notion पेज ID",
"form.integration.notion_token": "Notion गुप्त टोकन",
"form.integration.ntfy_activate": "प्रविष्टियाँ ntfy पर भेजें",
"form.integration.ntfy_api_token": "Ntfy API टोकन (वैकल्पिक)",
"form.integration.ntfy_icon_url": "Ntfy आइकन URL (वैकल्पिक)",
"form.integration.ntfy_internal_links": "क्लिक पर आंतरिक लिंक इस्तेमाल करें (वैकल्पिक)",
"form.integration.ntfy_password": "Ntfy पासवर्ड (वैकल्पिक)",
"form.integration.ntfy_topic": "Ntfy विषय (यदि फ़ीड में नहीं सेट है तो डिफ़ॉल्ट)",
"form.integration.ntfy_url": "Ntfy URL (वैकल्पिक, डिफ़ॉल्ट ntfy.sh)",
"form.integration.ntfy_username": "Ntfy उपयोगकर्ता नाम (वैकल्पिक)",
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
"form.integration.omnivore_activate": "Save entries to Omnivore",
"form.integration.omnivore_api_key": "Omnivore API key",
"form.integration.omnivore_url": "Omnivore API Endpoint",
"form.integration.omnivore_activate": "प्रविष्टियाँ Omnivore में सहेजें",
"form.integration.omnivore_api_key": "Omnivore API कुंजी",
"form.integration.omnivore_url": "Omnivore API समापन बिंदु",
"form.integration.pinboard_activate": "सहेजें विषयवस्तु प्रति का बोर्ड ",
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pushover_activate": "प्रविष्टियाँ Pushover पर भेजें",
"form.integration.pushover_device": "Pushover डिवाइस (वैकल्पिक)",
"form.integration.pushover_prefix": "Pushover URL उपसर्ग (वैकल्पिक)",
"form.integration.pushover_token": "Pushover ऐप्लिकेशन API टोकन",
"form.integration.pushover_user": "Pushover उपयोगकर्ता कुंजी",
"form.integration.raindrop_activate": "प्रविष्टियाँ Raindrop में सहेजें",
"form.integration.raindrop_collection_id": "संग्रह ID",
"form.integration.raindrop_tags": "टैग (कॉमा से अलग)",
"form.integration.raindrop_token": "(टेस्ट) टोकन",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
"form.integration.readeck_endpoint": "Readeck यूआरएल",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Readeck लेबल",
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shiori_username": "Shiori Username",
"form.integration.slack_activate": "Push entries to Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"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 सर्वर URL",
"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 वेबहुक लिंक",
"form.integration.telegram_bot_activate": "टेलीग्राम चैट के लिए नई विषय-कविता पुश करें",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"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": "बॉट टोकन",
"form.integration.telegram_chat_id": "चैट आईडी",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_topic_id": "टॉपिक ID",
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
@@ -335,16 +338,16 @@
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.integration.wallabag_tags": "Wallabag टैग",
"form.integration.webhook_activate": "वेबहुक सक्षम करें",
"form.integration.webhook_secret": "वेबहुक रहस्य",
"form.integration.webhook_url": "डिफ़ॉल्ट वेबहुक URL",
"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": "कस्टम सीएसएस",
@@ -356,14 +359,14 @@
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.prefs.label.entry_sorting": "प्रवेश छँटाई",
"form.prefs.label.entry_swipe": "टच स्क्रीन पर एंट्री स्वाइप सक्षम करें",
"form.prefs.label.external_font_hosts": "External font hosts",
"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": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "प्रविष्टियों को मैन्युअल रूप से पढ़ा हुआ चिह्नित करें",
"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": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"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": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
@@ -402,7 +405,7 @@
"menu.feeds": "फ़ीड",
"menu.flush_history": "इतिहास मिटाएँ",
"menu.history": "इतिहास",
"menu.home_page": "Home page",
"menu.home_page": "मुखपृष्ठ",
"menu.import": "आयात करे",
"menu.integrations": "एकीकरण",
"menu.logout": "लॉग आउट",
@@ -416,17 +419,17 @@
"menu.settings": "समायोजन",
"menu.shared_entries": "साझा प्रविष्टियां",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.show_only_starred_entries": "केवल पसंदीदा प्रविष्टियाँ दिखाएं",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.starred": "तारांकित",
"menu.title": "Menu",
"menu.title": "मेनू",
"menu.unread": "अपठित",
"menu.users": "उपयोगकर्ताओं",
"page.about.author": "रचयिता:",
"page.about.build_date": "बनाने की तिथि:",
"page.about.credits": "आभार सूची",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.db_usage": "डेटाबेस आकार:",
"page.about.git_commit": "Git कमिट:",
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
"page.about.go_version": "गो संस्करण:",
"page.about.license": "अनुज्ञा:",
@@ -455,10 +458,10 @@
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.title": "श्रेणियाँ",
"page.categories_count": [
"%d category",
"%d categories"
"%d श्रेणी",
"%d श्रेणियाँ"
],
"page.category_label": "Category: %s",
"page.category_label": "श्रेणी: %s",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_feed.etag_header": "ईटाग हैडर:",
"page.edit_feed.last_check": "अंतिम जांच:",
@@ -473,10 +476,10 @@
"%d समस्याए"
],
"page.feeds.last_check": "आखरी जाँच",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "अगली जाँच:",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.title": "फ़ीड",
"page.footer.elevator": "Back to top",
"page.footer.elevator": "ऊपर जाएँ",
"page.history.title": "इतिहास",
"page.import.title": "आयात",
"page.integration.bookmarklet": "बुकमार्कलेट",
@@ -522,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
"page.keyboard_shortcuts.toggle_star_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "प्रविष्टि संलग्नक खोलें/बंद करें",
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
"page.login.google_signin": "गूगल के साथ साइन इन करें",
@@ -530,7 +533,7 @@
"page.login.title": "साइन इन करें",
"page.login.webauthn_login": "पासकी से लॉगिन करें",
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।",
"page.new_api_key.title": "नई एपीआई कुंजी",
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
@@ -538,8 +541,8 @@
"page.offline.refresh_page": "पृष्ठ को ताज़ा करने का प्रयास करें",
"page.offline.title": "ऑफ़लाइन मोड",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d पढ़ी गई प्रविष्टि",
"%d पढ़ी गई प्रविष्टियाँ"
],
"page.search.title": "खोज का परिणाम",
"page.sessions.table.actions": "कार्रवाई",
@@ -553,35 +556,35 @@
"page.settings.title": "समायोजन",
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "कार्रवाई",
"page.settings.webauthn.added_on": "जोड़ा गया",
"page.settings.webauthn.delete": [
"%d पासकुंजी निकालें",
"%d पासकी हटाएं"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"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 shared entry",
"%d shared entries"
"%d साझा प्रविष्टि",
"%d साझा प्रविष्टियाँ"
],
"page.starred.title": "तारांकित",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d तारांकित प्रविष्टि",
"%d तारांकित प्रविष्टियाँ"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"कुल %d प्रविष्टि",
"कुल %d प्रविष्टियाँ"
],
"page.unread.title": "अपठित",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d अपठित प्रविष्टि",
"%d अपठित प्रविष्टियाँ"
],
"page.users.actions": "कार्रवाई",
"page.users.admin.no": "नहीं",
@@ -591,7 +594,7 @@
"page.users.never_logged": "कभी नहीं",
"page.users.title": "उपभोक्ता",
"page.users.username": "यूसर्नेम",
"page.webauthn_rename.title": "Rename Passkey",
"page.webauthn_rename.title": "पासकी का नाम बदलें",
"pagination.first": "पहला",
"pagination.last": "अंतिम",
"pagination.next": "अगला",
@@ -629,4 +632,4 @@
"time_elapsed.yesterday": "कल",
"tooltip.keyboard_shortcuts": "कुंजीपटल शॉर्टकट: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया"
}
}

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