Compare commits

...

46 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
115 changed files with 2808 additions and 744 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: binaries
path: miniflux-*
+9 -8
View File
@@ -13,7 +13,8 @@ on:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
jobs:
test-packages:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux' )
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
@@ -21,9 +22,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -42,9 +43,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -53,7 +54,7 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.deb"
@@ -68,9 +69,9 @@ jobs:
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
+9 -9
View File
@@ -24,7 +24,7 @@ jobs:
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -37,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
@@ -51,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 }}
@@ -73,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: .
@@ -90,7 +90,7 @@ jobs:
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.rpm"
+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
+3 -1
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...)
}
@@ -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
+7 -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"`
@@ -357,6 +360,8 @@ type APIKeyCreationRequest struct {
}
// 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)
}
+16 -18
View File
@@ -1,27 +1,28 @@
module miniflux.app/v2
// +heroku goVersion go1.24
// +heroku goVersion go1.26
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.15.0
github.com/go-webauthn/webauthn v0.16.1
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.8
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/term v0.40.0
github.com/tdewolff/minify/v2 v2.24.10
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
)
require (
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
require (
@@ -30,21 +31,18 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/tdewolff/parse/v2 v2.8.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.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
+30 -28
View File
@@ -17,19 +17,21 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -60,10 +62,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=
github.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=
github.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=
github.com/tdewolff/parse/v2 v2.8.10/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -83,10 +85,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+15 -15
View File
@@ -580,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -609,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
ExternalFontHosts: new(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -638,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
ExternalFontHosts: new("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
@@ -662,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
CustomJS: new("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -691,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("invalid_theme"),
Theme: new("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -721,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
@@ -1090,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
Title: new("new title"),
})
if err != nil {
t.Fatal(err)
@@ -1109,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
HideGlobally: new(true),
})
if err != nil {
t.Fatal(err)
@@ -1128,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
HideGlobally: new(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
@@ -1612,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
FeedURL: new("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
@@ -1653,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
FeedURL: new(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -1685,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: miniflux.SetOptionalField(int64(123456789)),
CategoryID: new(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -2718,8 +2718,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: miniflux.SetOptionalField("New title"),
Content: miniflux.SetOptionalField("New content"),
Title: new("New title"),
Content: new("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
+15 -12
View File
@@ -219,6 +219,11 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_BILIBILI_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
@@ -293,7 +298,7 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
@@ -353,11 +358,6 @@ func NewConfigOptions() *configOptions {
rawValue: "",
valueType: urlType,
},
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
parsedDuration: 120 * time.Second,
rawValue: "120",
@@ -791,8 +791,15 @@ func (c *configOptions) HTTPS() bool {
return c.options["HTTPS"].parsedBoolValue
}
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
if c == nil {
return false
}
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) InvidiousInstance() string {
@@ -847,10 +854,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
}
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
}
+33 -33
View File
@@ -1351,27 +1351,51 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
@@ -1442,30 +1466,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
+4
View File
@@ -1427,4 +1427,8 @@ var migrations = [...]func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
}
+35 -77
View File
@@ -38,6 +38,7 @@ var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
errSimultaneously = fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
)
// Serve handles Google Reader API calls.
@@ -69,12 +70,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = false
case StarredStream:
@@ -89,12 +90,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
return nil, errSimultaneously
}
tags[ReadStream] = true
case StarredStream:
@@ -452,7 +453,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, quickAddResponse{
NumResults: 1,
Query: newFeed.FeedURL,
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
StreamID: feedPrefix + strconv.FormatInt(newFeed.ID, 10),
StreamName: newFeed.Title,
})
}
@@ -584,9 +585,8 @@ func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID
func (h *handler) feedIconURL(f *model.Feed) string {
if f.Icon != nil && f.Icon.ExternalIconID != "" {
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
} else {
return ""
}
return ""
}
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
@@ -697,9 +697,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
streamPrefix := fmt.Sprintf(userStreamPrefix, userID)
userReadingList := streamPrefix + readingListStreamSuffix
userRead := streamPrefix + readStreamSuffix
userStarred := streamPrefix + starredStreamSuffix
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
@@ -739,6 +740,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Items: make([]contentItem, len(entries)),
}
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
@@ -747,7 +749,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories := make([]string, 0)
categories = append(categories, userReadingList)
if entry.Feed.Category.Title != "" {
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
categories = append(categories, labelPrefix+entry.Feed.Category.Title)
}
if entry.Status == model.EntryStatusRead {
categories = append(categories, userRead)
@@ -789,7 +791,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Content: entry.Content,
},
Origin: contentItemOrigin{
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
StreamID: feedPrefix + strconv.FormatInt(entry.FeedID, 10),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
@@ -890,7 +892,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
}
categoryModificationRequest := model.CategoryModificationRequest{
Title: model.SetOptionalField(destination.ID),
Title: new(destination.ID),
}
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
@@ -933,9 +935,10 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
})
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
ID: labelPrefix + category.Title,
Label: category.Title,
Type: "folder",
})
@@ -965,13 +968,14 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
return
}
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
result.Subscriptions = make([]subscriptionResponse, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
ID: feedPrefix + strconv.FormatInt(feed.ID, 10),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
Categories: []subscriptionCategoryResponse{{labelPrefix + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: h.feedIconURL(feed),
})
@@ -1102,27 +1106,11 @@ 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})
}
@@ -1139,28 +1127,11 @@ 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})
}
@@ -1178,12 +1149,20 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestModifiers) ([]itemRef, int, error) {
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
return nil, 0, err
}
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
@@ -1191,15 +1170,13 @@ 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) {
@@ -1231,30 +1208,11 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
}
}
}
rawEntryIDs, err := builder.GetEntryIDs()
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
json.ServerError(w, r, err)
return
}
var itemRefs = make([]itemRef, 0)
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
}
totalEntries, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
continuation := 0
if len(itemRefs)+rm.Offset < totalEntries {
continuation = len(itemRefs) + rm.Offset
}
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
+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)
}
}
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -25,7 +27,7 @@ type Client struct {
}
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
return &Client{servicesURL: serviceURL, baseURL: baseURL}
}
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
@@ -65,12 +67,12 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+28 -23
View File
@@ -4,11 +4,17 @@
package archiveorg
import (
"log/slog"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
@@ -18,26 +24,25 @@ func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
func (c *Client) SendURL(entryURL string) error {
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("archiveorg: unable to create request: %v", err)
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("archiveorg: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
}
return nil
}
+3 -2
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
return fmt.Errorf("betula: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("betula: unable to send request: %v", err)
+3 -1
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -77,12 +79,12 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
@@ -10,6 +10,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
}
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
+29 -24
View File
@@ -409,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.WebhookEnabled {
@@ -447,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
if err := client.SaveUrl(entry.URL); err != nil {
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
@@ -543,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Warn("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
@@ -642,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
client := pushover.NewClient(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
@@ -658,30 +665,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ type errorResponse struct {
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
URL string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
+3 -1
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
+55 -31
View File
@@ -10,9 +10,13 @@ import (
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
apiToken string
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "Invalid input",
},
},
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
description := input["description"].(string)
if len(description) != maxDescriptionLength {
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
}
func TestGraphQLMutation(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
variables, ok := req["variables"].(map[string]any)
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
input, ok := variables["input"].(map[string]any)
if !ok {
t.Fatal("Missing input in variables")
}
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
_ = strings.Join(splitTags, ",")
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
@@ -11,10 +11,13 @@ import (
"strings"
"testing"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
baseURL string
@@ -323,3 +326,21 @@ func TestNewClient(t *testing.T) {
})
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+5 -3
View File
@@ -11,7 +11,9 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,7 +42,7 @@ func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -89,7 +91,7 @@ func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*L
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -138,7 +140,7 @@ func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, te
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -57,7 +58,7 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
+12 -2
View File
@@ -13,6 +13,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -32,7 +33,16 @@ func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntf
if ntfyURL == "" {
ntfyURL = defaultNtfyURL
}
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
return &Client{
ntfyURL: ntfyURL,
ntfyTopic: ntfyTopic,
ntfyApiToken: ntfyApiToken,
ntfyUsername: ntfyUsername,
ntfyPassword: ntfyPassword,
ntfyIconURL: ntfyIconURL,
ntfyInternalLinks: ntfyInternalLinks,
ntfyPriority: ntfyPriority,
}
}
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
@@ -98,7 +108,7 @@ func (c *Client) makeRequest(payload any) error {
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("ntfy: unable to send request: %v", err)
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -43,7 +45,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
ContentType: "text/html",
})
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
return fmt.Errorf("nunux-keeper: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
@@ -55,7 +57,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
+15 -20
View File
@@ -11,7 +11,9 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -34,12 +36,6 @@ mutation SaveUrl($input: SaveUrlInput!) {
}
`
type SaveUrlInput struct {
ClientRequestId string `json:"clientRequestId"`
Source string `json:"source"`
Url string `json:"url"`
}
type errorResponse struct {
Errors []struct {
Message string `json:"message"`
@@ -49,31 +45,27 @@ type errorResponse struct {
type successResponse struct {
Data struct {
SaveUrl struct {
Url string `json:"url"`
ClientRequestId string `json:"clientRequestId"`
URL string `json:"url"`
ClientRequestID string `json:"clientRequestId"`
} `json:"saveUrl"`
} `json:"data"`
}
type Client interface {
SaveUrl(url string) error
}
type client struct {
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
}
func NewClient(apiToken string, apiEndpoint string) Client {
func NewClient(apiToken string, apiEndpoint string) *Client {
if apiEndpoint == "" {
apiEndpoint = defaultApiEndpoint
}
return &client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}
}
func (c *client) SaveUrl(url string) error {
func (c *Client) SaveURL(url string) error {
var payload = map[string]any{
"query": mutation,
"variables": map[string]any{
@@ -105,7 +97,7 @@ func (c *client) SaveUrl(url string) error {
defer resp.Body.Close()
b, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("omnivore: failed to parse response: %s", err)
return fmt.Errorf("omnivore: failed to parse response: %v", err)
}
if resp.StatusCode >= 400 {
@@ -113,11 +105,14 @@ func (c *client) SaveUrl(url string) error {
if err = json.Unmarshal(b, &errResponse); err != nil {
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
}
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
if len(errResponse.Errors) > 0 {
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
}
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
}
var successReponse successResponse
if err = json.Unmarshal(b, &successReponse); err != nil {
var successResp successResponse
if err = json.Unmarshal(b, &successResp); err != nil {
return fmt.Errorf("omnivore: failed to parse response, however the request appears successful, is the url correct?: status=%d %s", resp.StatusCode, string(b))
}
+3 -4
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -57,10 +58,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
return fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pinboard: unable to send request: %v", err)
@@ -90,10 +90,9 @@ func (c *Client) getBookmark(entryURL string) (*Post, error) {
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
+3 -3
View File
@@ -13,7 +13,7 @@ import (
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
type Post struct {
XMLName xml.Name `xml:"post"`
Url string `xml:"href,attr"`
URL string `xml:"href,attr"`
Description string `xml:"description,attr"`
Tags string `xml:"tag,attr"`
Extended string `xml:"extended,attr"`
@@ -30,7 +30,7 @@ type posts struct {
func NewPost(url string, description string) *Post {
return &Post{
Url: url,
URL: url,
Description: description,
Date: time.Now(),
Toread: "no",
@@ -48,7 +48,7 @@ func (p *Post) SetToread() {
}
func (p *Post) AddValues(values url.Values) {
values.Add("url", p.Url)
values.Add("url", p.URL)
values.Add("description", p.Description)
values.Add("tags", p.Tags)
if p.Toread != "" {
+15 -13
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -31,7 +33,7 @@ type Client struct {
priority int
}
type Message struct {
type message struct {
Token string `json:"token"`
User string `json:"user"`
@@ -44,14 +46,14 @@ type Message struct {
Device string `json:"device,omitempty"`
}
type ErrorResponse struct {
type errorResponse struct {
User string `json:"user"`
Errors []string `json:"errors"`
Status int `json:"status"`
Request string `json:"request"`
}
func New(user, token string, priority int, device, urlPrefix string) *Client {
func NewClient(user, token string, priority int, device, urlPrefix string) *Client {
if urlPrefix == "" {
urlPrefix = defaultPushoverURL
}
@@ -76,7 +78,7 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
return errors.New("pushover token and user are required")
}
for _, entry := range entries {
msg := &Message{
msg := &message{
User: c.user,
Token: c.token,
Device: c.device,
@@ -94,45 +96,45 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
)
if err := c.makeRequest(msg); err != nil {
return fmt.Errorf("c.makeRequest: %w", err)
return fmt.Errorf("pushover: unable to send message: %w", err)
}
}
return nil
}
func (c *Client) makeRequest(payload *Message) error {
func (c *Client) makeRequest(payload *message) error {
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
return fmt.Errorf("pushover: unable to encode request body: %w", err)
}
url := c.prefix + "/1/messages.json"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
return fmt.Errorf("pushover: unable to create request: %w", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("httpClient.Do: %w", err)
return fmt.Errorf("pushover: unable to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
errorMessage := resp.Status
var errResp ErrorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
var errResp errorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil {
if len(errResp.Errors) > 0 {
errorMessage = strings.Join(errResp.Errors, ",")
}
}
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
return fmt.Errorf("pushover: API error: status=%d %s", resp.StatusCode, errorMessage)
}
return nil
+4 -2
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -53,7 +55,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
@@ -70,7 +72,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection,omitempty"`
Collection collection `json:"collection"`
Tags []string `json:"tags"`
}
+7 -5
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
var request *http.Request
if c.onlyURL {
requestBodyJson, err := json.Marshal(&readeckBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Labels: labelsSplit,
})
@@ -91,7 +93,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
}
contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL,
URL: entryURL,
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
})
if err != nil {
@@ -120,7 +122,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readeck: unable to send request: %v", err)
@@ -135,7 +137,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
}
type readeckBookmark struct {
Url string `json:"url"`
URL string `json:"url"`
Title string `json:"title"`
Labels []string `json:"labels,omitempty"`
}
@@ -145,6 +147,6 @@ type contentHeader struct {
}
type partContentHeader struct {
Url string `json:"url"`
URL string `json:"url"`
ContentHeader contentHeader `json:"headers"`
}
+29 -7
View File
@@ -11,9 +11,13 @@ import (
"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>"
@@ -98,11 +102,11 @@ func TestCreateBookmark(t *testing.T) {
if !strings.HasPrefix(ct, "multipart/form-data;") {
t.Errorf("expected multipart/form-data, got %s", ct)
}
boundaryIdx := strings.Index(ct, "boundary=")
if boundaryIdx == -1 {
_, after, ok := strings.Cut(ct, "boundary=")
if !ok {
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
}
boundary := ct[boundaryIdx+len("boundary="):]
boundary := after
mr := multipart.NewReader(r.Body, boundary)
seenLabels := []string{}
@@ -132,12 +136,12 @@ func TestCreateBookmark(t *testing.T) {
case "resource":
// First line is JSON header, then newline, then content
all := string(data)
idx := strings.IndexByte(all, '\n')
if idx == -1 {
before, after, ok := strings.Cut(all, "\n")
if !ok {
t.Fatalf("resource content missing header separator")
}
headerJSON := all[:idx]
resourceBody = all[idx+1:]
headerJSON := before
resourceBody = after
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
t.Fatalf("invalid resource header JSON: %v", err)
}
@@ -258,3 +262,21 @@ func TestNewClient(t *testing.T) {
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) CreateDocument(entryURL string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
+14 -8
View File
@@ -11,6 +11,10 @@ import (
"net/url"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
@@ -27,7 +31,7 @@ type BridgeMeta struct {
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
endpointURL, err := url.Parse(rssBridgeURL)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
return nil, fmt.Errorf("rssbridge: unable to parse bridge URL: %w", err)
}
values := endpointURL.Query()
@@ -43,14 +47,16 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
return nil, fmt.Errorf("rssbridge: unable to create request: %w", err)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
return nil, fmt.Errorf("rssbridge: unable to execute request: %w", err)
}
defer response.Body.Close()
@@ -58,13 +64,13 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
return nil, nil
}
if response.StatusCode > 400 {
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
if response.StatusCode >= 400 {
return nil, fmt.Errorf("rssbridge: unexpected status code %d", response.StatusCode)
}
var bridgeResponse []*Bridge
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
return nil, fmt.Errorf("rssbridge: unable to decode bridge response: %w", err)
}
for _, bridge := range bridgeResponse {
@@ -76,7 +82,7 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
if strings.HasPrefix(bridge.URL, "./") {
bridge.URL = rssBridgeURL + bridge.URL[2:]
slog.Debug("Rewrited relative RSS bridge URL",
slog.Debug("Rewrote relative RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
+4 -2
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -59,7 +61,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shaarli: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
func (c *Client) generateBearerToken() string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
payload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"iat":%d}`, time.Now().Unix()))
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret))
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -65,7 +67,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
@@ -80,7 +82,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return nil
}
func (c *Client) authenticate() (token string, err error) {
func (c *Client) authenticate() (string, error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
@@ -100,7 +102,7 @@ func (c *Client) authenticate() (token string, err error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -81,12 +83,12 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -50,7 +51,7 @@ func (c *Client) GetMe() (*User, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
@@ -90,7 +91,7 @@ func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
+18 -8
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -30,7 +32,15 @@ type Client struct {
}
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
return &Client{
baseURL: baseURL,
clientID: clientID,
clientSecret: clientSecret,
username: username,
password: password,
tags: tags,
onlyURL: onlyURL,
}
}
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
@@ -49,7 +59,7 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
return fmt.Errorf("wallabag: unable to generate entries endpoint: %v", err)
}
if c.onlyURL {
@@ -63,12 +73,12 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
Tags: tags,
})
if err != nil {
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
return fmt.Errorf("wallabag: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("wallbag: unable to create request: %v", err)
return fmt.Errorf("wallabag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -76,7 +86,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("wallabag: unable to send request: %v", err)
@@ -100,19 +110,19 @@ func (c *Client) getAccessToken() (string, error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
if err != nil {
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
return "", fmt.Errorf("wallabag: unable to generate token endpoint: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return "", fmt.Errorf("wallbag: unable to create request: %v", err)
return "", fmt.Errorf("wallabag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
@@ -10,9 +10,13 @@ import (
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateEntry(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
entryURL := "https://example.com"
entryTitle := "title"
entryContent := "content"
@@ -319,3 +323,21 @@ func TestNewClient(t *testing.T) {
})
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+3 -1
View File
@@ -11,7 +11,9 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
@@ -132,7 +134,7 @@ func (c *Client) makeRequest(eventType string, payload any) error {
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
request.Header.Set("X-Miniflux-Event-Type", eventType)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("webhook: unable to send request: %v", err)
+2
View File
@@ -99,12 +99,14 @@ func TestMissingTranslations(t *testing.T) {
func TestTranslationFilePluralForms(t *testing.T) {
var numberOfPluralFormsPerLanguage = map[string]int{
"ar_SA": 6,
"de_DE": 2,
"el_EL": 2,
"en_US": 2,
"es_ES": 2,
"fi_FI": 2,
"fr_FR": 2,
"gl_ES": 2,
"hi_IN": 2,
"id_ID": 1,
"it_IT": 2,
+2
View File
@@ -5,12 +5,14 @@ package locale // import "miniflux.app/v2/internal/locale"
// AvailableLanguages is the list of available languages.
var AvailableLanguages = map[string]string{
"ar_SA": "العربية",
"de_DE": "Deutsch",
"el_EL": "Ελληνικά",
"en_US": "English",
"es_ES": "Español",
"fi_FI": "Suomi",
"fr_FR": "Français",
"gl_ES": "Galego",
"hi_IN": "हिन्दी",
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
+5
View File
@@ -31,6 +31,11 @@ func getPluralForm(lang string, n int) int {
default:
return 2
}
case "gl_ES":
if n != 1 {
return 1
}
return 0
case "id_ID", "ja_JP":
return 0
case "pl_PL":
+7
View File
@@ -44,6 +44,13 @@ func TestPluralRules(t *testing.T) {
2: 1, // n > 1
5: 1, // n > 1
},
// Galician (gl_ES) - n != 1
"gl_ES": {
0: 1, // n != 1
1: 0, // n == 1
2: 1, // n != 1
5: 1, // n != 1
},
// Indonesian (id_ID) - always form 0
"id_ID": {
0: 0,
+707
View File
@@ -0,0 +1,707 @@
{
"action.cancel": "إلغاء",
"action.download": "تحميل",
"action.edit": "تعديل",
"action.home_screen": "إضافة إلى الشاشة الرئيسية",
"action.import": "استيراد",
"action.login": "تسجيل الدخول",
"action.or": "أو",
"action.remove": "حذف",
"action.remove_feed": "حذف هذا المصدر",
"action.save": "حفظ",
"action.subscribe": "اشتراك",
"action.update": "تحديث",
"alert.account_linked": "تم ربط حسابك الخارجي!",
"alert.account_unlinked": "تم فك ارتباط حسابك الخارجي!",
"alert.background_feed_refresh": "يتم تحديث جميع المصادر في الخلفية. يمكنك الاستمرار في استخدام Miniflux أثناء تشغيل هذه العملية.",
"alert.feed_error": "توجد مشكلة في هذا المصدر",
"alert.no_starred": "لا توجد في المُفضلة.",
"alert.no_category": "لا توجد فئة.",
"alert.no_category_entry": "لا توجد مقالات في هذه الفئة.",
"alert.no_feed": "ليس لديك أي مصادر.",
"alert.no_feed_entry": "لا توجد مقالات لهذا المصدر.",
"alert.no_feed_in_category": "لا يوجد مصدر لهذه الفئة.",
"alert.no_history": "لا يوجد سجل في الوقت الحالي.",
"alert.no_search_result": "لا توجد نتائج لهذا البحث.",
"alert.no_shared_entry": "لا توجد مشاركات.",
"alert.no_tag_entry": "لا توجد مقالات تطابق هذا الوسم.",
"alert.no_unread_entry": "لا توجد مقالات غير مقروءة.",
"alert.no_user": "أنت المستخدم الوحيد.",
"alert.prefs_saved": "تم حفظ التفضيلات!",
"alert.too_many_feeds_refresh": [
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقة واحدة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقتين قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقائق قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى."
],
"confirm.loading": "جارٍ التحميل...",
"confirm.no": "لا",
"confirm.question": "هل أنت متأكد؟",
"confirm.question.refresh": "هل أنت متأكد أنك تريد فرض التحديث؟",
"confirm.yes": "نعم",
"enclosure_media_controls.seek": "بحث:",
"enclosure_media_controls.seek.title": "بحث %s ثانية",
"enclosure_media_controls.speed": "السرعة:",
"enclosure_media_controls.speed.faster": "أسرع",
"enclosure_media_controls.speed.faster.title": "أسرع بـ %sx",
"enclosure_media_controls.speed.reset": "إعادة تعيين",
"enclosure_media_controls.speed.reset.title": "إعادة تعيين السرعة إلى 1x",
"enclosure_media_controls.speed.slower": "أبطأ",
"enclosure_media_controls.speed.slower.title": "أبطأ بـ %sx",
"entry.starred.toast.off": "أزيلت من المفضلة",
"entry.starred.toast.on": "أضيفت للمفضلة",
"entry.starred.toggle.off": "إزالة من المفضلة",
"entry.starred.toggle.on": "إضافة للمفضلة",
"entry.comments.label": "تعليقات",
"entry.comments.title": "عرض التعليقات",
"entry.estimated_reading_time": [
"قراءة %d دقيقة",
"قراءة دقيقة واحدة",
"قراءة دقيقتين",
"قراءة %d دقائق",
"قراءة %d دقيقة",
"قراءة %d دقيقة"
],
"entry.external_link.label": "رابط خارجي",
"entry.save.completed": "تم!",
"entry.save.label": "حفظ",
"entry.save.title": "حفظ هذا المقال",
"entry.save.toast.completed": "تم حفظ المقال",
"entry.scraper.completed": "تم!",
"entry.scraper.label": "تحميل",
"entry.scraper.title": "جلب المحتوى الأصلي",
"entry.share.label": "مشاركة",
"entry.share.title": "مشاركة هذا المقال",
"entry.shared_entry.label": "مشاركة",
"entry.shared_entry.title": "فتح الرابط العام",
"entry.state.loading": "جارٍ التحميل...",
"entry.state.saving": "جارٍ الحفظ...",
"entry.status.mark_as_read": "تحديد كمقروء",
"entry.status.mark_as_unread": "تحديد كغير مقروء",
"entry.status.title": "تغيير حالة المقال",
"entry.status.toast.read": "تم تحديده كمقروء",
"entry.status.toast.unread": "تم تحديده كغير مقروء",
"entry.tags.label": "الوسوم:",
"entry.tags.more_tags_label": [
"إظهار %d وسم",
"إظهار وسم واحد",
"إظهار وسمين",
"إظهار %d وسوم",
"إظهار %d وسماً",
"إظهار %d وسماً"
],
"entry.unshare.label": "إلغاء المشاركة",
"error.api_key_already_exists": "مفتاح API هذا موجود بالفعل.",
"error.bad_credentials": "اسم المستخدم أو كلمة المرور غير صالحة.",
"error.category_already_exists": "هذه الفئة موجودة بالفعل.",
"error.category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
"error.database_error": "خطأ في قاعدة البيانات: %v.",
"error.different_passwords": "كلمات المرور غير متطابقة.",
"error.duplicate_fever_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Fever!",
"error.duplicate_googlereader_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Google Reader!",
"error.linktaco_missing_required_fields": "مطلوب رمز LinkTaco API و Organization Slug",
"error.duplicate_linked_account": "يوجد بالفعل شخص مرتبط بهذا الموفر!",
"error.duplicated_feed": "هذا المصدر موجود بالفعل.",
"error.empty_file": "هذا الملف فارغ.",
"error.entries_per_page_invalid": "عدد المقالات في الصفحة غير صالح.",
"error.feed_already_exists": "هذا المصدر موجود بالفعل.",
"error.feed_category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
"error.feed_format_not_detected": "تعذر اكتشاف تنسيق المصدر: %v.",
"error.feed_invalid_blocklist_rule": "قاعدة قائمة الحظر غير صالحة.",
"error.feed_invalid_keeplist_rule": "قاعدة قائمة الاحتفاظ غير صالحة.",
"error.feed_mandatory_fields": "الرابط والفئة إلزاميان.",
"error.feed_not_found": "هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.",
"error.feed_title_not_empty": "عنوان المصدر لا يمكن أن يكون فارغاً.",
"error.feed_url_not_empty": "رابط المصدر لا يمكن أن يكون فارغاً.",
"error.fields_mandatory": "جميع الحقول إلزامية.",
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
"error.http_client_error": "خطأ في عميل HTTP: %v.",
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
"error.http_gateway_timeout": "الموقع غير متاح حالياً بسبب انتهاء مهلة البوابة. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_internal_server_error": "الموقع غير متاح حالياً بسبب خطأ في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_not_authorized": "الوصول إلى هذا الموقع غير مصرح به. قد يكون اسم المستخدم أو كلمة المرور غير صحيحة.",
"error.http_resource_not_found": "المورد المطلوب غير موجود. يرجى التحقق من الرابط.",
"error.http_response_too_large": "استجابة HTTP كبيرة جداً. يمكنك زيادة حد حجم استجابة HTTP في الإعدادات العامة (يتطلب إعادة تشغيل الخادم).",
"error.http_service_unavailable": "الموقع غير متاح حالياً بسبب خطأ داخلي في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_too_many_requests": "أنشأ Miniflux عدداً كبيراً جداً من الطلبات لهذا الموقع. يرجى المحاولة لاحقاً أو تغيير إعدادات التطبيق.",
"error.http_unexpected_status_code": "الموقع غير متاح حالياً بسبب رمز حالة HTTP غير متوقع: %d. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.invalid_categories_sorting_order": "ترتيب فرز الفئات غير صالح.",
"error.invalid_default_home_page": "الصفحة الرئيسية الافتراضية غير صالحة!",
"error.invalid_display_mode": "وضع عرض تطبيق الويب غير صالح.",
"error.invalid_entry_direction": "اتجاه المقال غير صالح.",
"error.invalid_entry_order": "ترتيب المقال غير صالح.",
"error.invalid_feed_proxy_url": "رابط الوكيل (Proxy) غير صالح.",
"error.invalid_feed_url": "رابط المصدر غير صالح.",
"error.invalid_gesture_nav": "تصفح الإيماءات غير صالح.",
"error.invalid_language": "اللغة غير صالحة.",
"error.invalid_site_url": "رابط الموقع غير صالح.",
"error.invalid_theme": "السمة غير صالحة.",
"error.invalid_timezone": "المنطقة الزمنية غير صالحة.",
"error.network_operation": "Miniflux غير قادر على الوصول إلى هذا الموقع بسبب خطأ في الشبكة: %v.",
"error.network_timeout": "هذا الموقع بطيء جداً وانتهى وقت الطلب: %v",
"error.password_min_length": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.",
"error.proxy_url_not_empty": "رابط الوكيل لا يمكن أن يكون فارغاً.",
"error.settings_block_rule_fieldname_invalid": "قاعدة الحظر غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
"error.settings_block_rule_invalid_regex": "قاعدة الحظر غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
"error.settings_block_rule_regex_required": "قاعدة الحظر غير صالحة: لم يتم توفير نمط للقاعدة #%d",
"error.settings_block_rule_separator_required": "قاعدة الحظر غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
"error.settings_invalid_domain_list": "قائمة النطاقات غير صالحة. يرجى تقديم قائمة مفصولة بمسافات للنطاقات.",
"error.settings_keep_rule_fieldname_invalid": "قاعدة الاحتفاظ غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
"error.settings_keep_rule_invalid_regex": "قاعدة الاحتفاظ غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
"error.settings_keep_rule_regex_required": "قاعدة الاحتفاظ غير صالحة: لم يتم توفير نمط للقاعدة #%d",
"error.settings_keep_rule_separator_required": "قاعدة الاحتفاظ غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
"error.settings_mandatory_fields": "حقول اسم المستخدم، السمة، اللغة، والمنطقة الزمنية إلزامية.",
"error.settings_media_playback_rate_range": "سرعة التشغيل خارج النطاق",
"error.settings_reading_speed_is_positive": "يجب أن تكون سرعة القراءة أرقاماً صحيحة موجبة.",
"error.site_url_not_empty": "رابط الموقع لا يمكن أن يكون فارغاً.",
"error.subscription_not_found": "تعذر العثور على أي مصدر.",
"error.title_required": "العنوان إلزامي.",
"error.tls_error": "خطأ TLS: %q. يمكنك تعطيل التحقق من TLS في إعدادات المصدر إذا كنت ترغب في ذلك.",
"error.unable_to_create_api_key": "تعذر إنشاء مفتاح API هذا.",
"error.unable_to_create_category": "تعذر إنشاء هذه الفئة.",
"error.unable_to_create_user": "تعذر إنشاء هذا المستخدم.",
"error.unable_to_detect_rssbridge": "تعذر اكتشاف المصدر باستخدام RSS-Bridge: %v.",
"error.unable_to_parse_feed": "تعذر تحليل هذا المصدر: %v.",
"error.unable_to_update_category": "تعذر تحديث هذه الفئة.",
"error.unable_to_update_feed": "تعذر تحديث هذا المصدر.",
"error.unable_to_update_user": "تعذر تحديث هذا المستخدم.",
"error.unlink_account_without_password": "يجب عليك تحديد كلمة مرور وإلا لن تتمكن من تسجيل الدخول مرة أخرى.",
"error.user_already_exists": "هذا المستخدم موجود بالفعل.",
"error.user_mandatory_fields": "اسم المستخدم إلزامي.",
"form.api_key.label.description": "تسمية مفتاح API",
"form.category.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
"form.category.label.title": "العنوان",
"form.feed.fieldset.general": "عام",
"form.feed.fieldset.integration": "خدمات الطرف الثالث",
"form.feed.fieldset.network_settings": "إعدادات الشبكة",
"form.feed.fieldset.rules": "قواعد",
"form.feed.label.allow_self_signed_certificates": "السماح بالشهادات الموقعة ذاتياً أو غير الصالحة",
"form.feed.label.apprise_service_urls": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
"form.feed.label.block_filter_entry_rules": "قواعد حظر المقالات",
"form.feed.label.blocklist_rules": "مرشحات الحظر المعتمدة على Regex",
"form.feed.label.category": "الفئة",
"form.feed.label.cookie": "تعيين ملفات تعريف الارتباط (Cookies)",
"form.feed.label.crawler": "جلب المحتوى الأصلي",
"form.feed.label.ignore_entry_updates": "تجاهل تحديثات المقالات",
"form.feed.label.description": "الوصف",
"form.feed.label.disable_http2": "تعطيل HTTP/2 لتجنب التتبع",
"form.feed.label.disabled": "لا تقم بتحديث هذا المصدر",
"form.feed.label.feed_password": "كلمة مرور المصدر",
"form.feed.label.feed_url": "رابط المصدر",
"form.feed.label.feed_username": "اسم مستخدم المصدر",
"form.feed.label.fetch_via_proxy": "استخدم الوكيل الذي تم تكوينه على مستوى التطبيق",
"form.feed.label.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
"form.feed.label.ignore_http_cache": "تجاهل ذاكرة التخزين المؤقت لـ HTTP",
"form.feed.label.keep_filter_entry_rules": "قواعد السماح للمقالات",
"form.feed.label.keeplist_rules": "مرشحات الاحتفاظ المعتمدة على Regex",
"form.feed.label.no_media_player": "بدون مشغل الوسائط (صوت / فيديو)",
"form.feed.label.ntfy_activate": "إرسال المقالات إلى ntfy",
"form.feed.label.ntfy_default_priority": "أولوية Ntfy الافتراضية",
"form.feed.label.ntfy_high_priority": "أولوية Ntfy عالية",
"form.feed.label.ntfy_low_priority": "أولوية Ntfy منخفضة",
"form.feed.label.ntfy_max_priority": "أقصى أولوية Ntfy",
"form.feed.label.ntfy_min_priority": "أدنى أولوية Ntfy",
"form.feed.label.ntfy_priority": "أولوية Ntfy",
"form.feed.label.ntfy_topic": "موضوع Ntfy (اختياري)",
"form.feed.label.proxy_url": "رابط الوكيل (Proxy)",
"form.feed.label.pushover_activate": "إرسال المقالات إلى Pushover",
"form.feed.label.pushover_default_priority": "الأولوية الافتراضية",
"form.feed.label.pushover_high_priority": "أولوية عالية",
"form.feed.label.pushover_low_priority": "أولوية منخفضة",
"form.feed.label.pushover_max_priority": "أولوية قصوى",
"form.feed.label.pushover_min_priority": "أولوية دنيا",
"form.feed.label.pushover_priority": "أولوية رسالة Pushover",
"form.feed.label.rewrite_rules": "قواعد إعادة كتابة المحتوى",
"form.feed.label.scraper_rules": "قواعد الكاشط (Scraper)",
"form.feed.label.site_url": "رابط الموقع",
"form.feed.label.title": "العنوان",
"form.feed.label.urlrewrite_rules": "قواعد إعادة كتابة الروابط",
"form.feed.label.user_agent": "تجاوز وكيل المستخدم الافتراضي (User Agent)",
"form.feed.label.webhook_url": "تجاوز رابط الويب هوك (Webhook)",
"form.import.label.file": "ملف OPML",
"form.import.label.url": "الرابط",
"form.integration.archiveorg_activate": "إرسال المقالات إلى archive.org",
"form.integration.apprise_activate": "إرسال المقالات إلى Apprise",
"form.integration.apprise_services_url": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
"form.integration.apprise_url": "رابط Apprise API",
"form.integration.betula_activate": "حفظ المقالات في Betula",
"form.integration.betula_token": "رمز Betula",
"form.integration.betula_url": "رابط خادم Betula",
"form.integration.cubox_activate": "حفظ المقالات في Cubox",
"form.integration.cubox_api_link": "رابط Cubox API",
"form.integration.discord_activate": "إرسال المقالات إلى Discord",
"form.integration.discord_webhook_link": "رابط Discord Webhook",
"form.integration.espial_activate": "حفظ المقالات في Espial",
"form.integration.espial_api_key": "مفتاح Espial API",
"form.integration.espial_endpoint": "نقطة نهاية Espial API",
"form.integration.espial_tags": "وسوم Espial",
"form.integration.fever_activate": "تفعيل Fever API",
"form.integration.fever_endpoint": "نقطة نهاية Fever API:",
"form.integration.fever_password": "كلمة مرور Fever",
"form.integration.fever_username": "اسم مستخدم Fever",
"form.integration.googlereader_activate": "تفعيل Google Reader API",
"form.integration.googlereader_endpoint": "نقطة نهاية Google Reader API:",
"form.integration.googlereader_password": "كلمة مرور Google Reader",
"form.integration.googlereader_username": "اسم مستخدم Google Reader",
"form.integration.instapaper_activate": "حفظ المقالات في Instapaper",
"form.integration.instapaper_password": "كلمة مرور Instapaper",
"form.integration.instapaper_username": "اسم مستخدم Instapaper",
"form.integration.karakeep_activate": "حفظ المقالات في Karakeep",
"form.integration.karakeep_api_key": "مفتاح Karakeep API",
"form.integration.karakeep_url": "نقطة نهاية Karakeep API",
"form.integration.karakeep_tags": "وسوم Karakeep",
"form.integration.linkace_activate": "حفظ المقالات في LinkAce",
"form.integration.linkace_api_key": "مفتاح LinkAce API",
"form.integration.linkace_check_disabled": "تعطيل فحص الرابط",
"form.integration.linkace_endpoint": "نقطة نهاية LinkAce API",
"form.integration.linkace_is_private": "تحديد الرابط كخاص",
"form.integration.linkace_tags": "وسوم LinkAce",
"form.integration.linkding_activate": "حفظ المقالات في Linkding",
"form.integration.linkding_api_key": "مفتاح Linkding API",
"form.integration.linkding_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
"form.integration.linkding_endpoint": "نقطة نهاية Linkding API",
"form.integration.linkding_tags": "وسوم Linkding",
"form.integration.linktaco_activate": "حفظ المقالات في LinkTaco",
"form.integration.linktaco_api_token": "رمز LinkTaco API",
"form.integration.linktaco_api_token_hint": "احصل على رمز الوصول الشخصي الخاص بك في",
"form.integration.linktaco_org_slug": "Slug المؤسسة",
"form.integration.linktaco_tags": "الوسوم (بحد أقصى 10، مفصولة بفواصل)",
"form.integration.linktaco_tags_hint": "حد أقصى 10 وسوم، مفصولة بفواصل",
"form.integration.linktaco_visibility": "الرؤية",
"form.integration.linktaco_visibility_public": "عام",
"form.integration.linktaco_visibility_private": "خاص",
"form.integration.linktaco_visibility_hint": "تتطلب رؤية خاصة حساب LinkTaco مدفوع",
"form.integration.linkwarden_activate": "حفظ المقالات في Linkwarden",
"form.integration.linkwarden_api_key": "مفتاح Linkwarden API",
"form.integration.linkwarden_endpoint": "رابط Linkwarden الأساسي",
"form.integration.linkwarden_collection_id": "معرف مجموعة Linkwarden",
"form.integration.matrix_bot_activate": "إرسال مقالات جديدة إلى Matrix",
"form.integration.matrix_bot_chat_id": "معرف غرفة Matrix",
"form.integration.matrix_bot_password": "كلمة مرور مستخدم Matrix",
"form.integration.matrix_bot_url": "رابط خادم Matrix",
"form.integration.matrix_bot_user": "اسم المستخدم في Matrix",
"form.integration.notion_activate": "حفظ المقالات في Notion",
"form.integration.notion_page_id": "معرف صفحة Notion",
"form.integration.notion_token": "رمز Notion السري",
"form.integration.ntfy_activate": "إرسال المقالات إلى ntfy",
"form.integration.ntfy_api_token": "رمز Ntfy API (اختياري)",
"form.integration.ntfy_icon_url": "رابط أيقونة Ntfy (اختياري)",
"form.integration.ntfy_internal_links": "استخدام روابط داخلية عند النقر (اختياري)",
"form.integration.ntfy_password": "كلمة مرور Ntfy (اختياري)",
"form.integration.ntfy_topic": "موضوع Ntfy (يستخدم الافتراضي إذا لم يُحدد في المصدر)",
"form.integration.ntfy_url": "رابط Ntfy (اختياري، الافتراضي هو ntfy.sh)",
"form.integration.ntfy_username": "اسم مستخدم Ntfy (اختياري)",
"form.integration.nunux_keeper_activate": "حفظ المقالات في Nunux Keeper",
"form.integration.nunux_keeper_api_key": "مفتاح Nunux Keeper API",
"form.integration.nunux_keeper_endpoint": "نقطة نهاية Nunux Keeper API",
"form.integration.omnivore_activate": "حفظ المقالات في Omnivore",
"form.integration.omnivore_api_key": "مفتاح Omnivore API",
"form.integration.omnivore_url": "نقطة نهاية Omnivore API",
"form.integration.pinboard_activate": "حفظ المقالات في Pinboard",
"form.integration.pinboard_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
"form.integration.pinboard_tags": "وسوم Pinboard",
"form.integration.pinboard_token": "رمز Pinboard API",
"form.integration.pushover_activate": "إرسال المقالات إلى Pushover",
"form.integration.pushover_device": "جهاز Pushover (اختياري)",
"form.integration.pushover_prefix": "بادئة رابط Pushover (اختياري)",
"form.integration.pushover_token": "رمز API لتطبيق Pushover",
"form.integration.pushover_user": "مفتاح مستخدم Pushover",
"form.integration.raindrop_activate": "حفظ المقالات في Raindrop",
"form.integration.raindrop_collection_id": "معرف المجموعة",
"form.integration.raindrop_tags": "الوسوم (مفصولة بفواصل)",
"form.integration.raindrop_token": "رمز (تجريبي)",
"form.integration.readeck_activate": "حفظ المقالات في readeck",
"form.integration.readeck_api_key": "مفتاح Readeck API",
"form.integration.readeck_endpoint": "رابط Readeck",
"form.integration.readeck_labels": "تسميات Readeck",
"form.integration.readeck_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
"form.integration.readeck_push_activate": "إرسال المقالات الجديدة تلقائياً إلى Readeck",
"form.integration.readwise_activate": "حفظ المقالات في Readwise Reader",
"form.integration.readwise_api_key": "رمز الوصول لـ Readwise Reader",
"form.integration.readwise_api_key_link": "احصل على رمز الوصول لـ Readwise الخاص بك",
"form.integration.rssbridge_activate": "تحقق من RSS-Bridge عند إضافة الاشتراكات",
"form.integration.rssbridge_token": "رمز مصادقة RSS-Bridge",
"form.integration.rssbridge_url": "رابط خادم RSS-Bridge",
"form.integration.shaarli_activate": "حفظ المقالات في Shaarli",
"form.integration.shaarli_api_secret": "سر Shaarli API",
"form.integration.shaarli_endpoint": "رابط Shaarli",
"form.integration.shiori_activate": "حفظ المقالات في Shiori",
"form.integration.shiori_endpoint": "نقطة نهاية Shiori API",
"form.integration.shiori_password": "كلمة مرور Shiori",
"form.integration.shiori_username": "اسم مستخدم Shiori",
"form.integration.slack_activate": "إرسال المقالات إلى Slack",
"form.integration.slack_webhook_link": "رابط Slack Webhook",
"form.integration.telegram_bot_activate": "إرسال المقالات الجديدة إلى دردشة Telegram",
"form.integration.telegram_bot_disable_buttons": "تعطيل الأزرار",
"form.integration.telegram_bot_disable_notification": "تعطيل الإشعارات",
"form.integration.telegram_bot_disable_web_page_preview": "تعطيل معاينة الرابط",
"form.integration.telegram_bot_token": "رمز البوت (Token)",
"form.integration.telegram_chat_id": "معرف الدردشة",
"form.integration.telegram_topic_id": "معرف الموضوع (Topic ID)",
"form.integration.wallabag_activate": "حفظ المقالات في Wallabag",
"form.integration.wallabag_client_id": "معرف عميل Wallabag",
"form.integration.wallabag_client_secret": "سر عميل Wallabag",
"form.integration.wallabag_endpoint": "رابط Wallabag الأساسي",
"form.integration.wallabag_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
"form.integration.wallabag_password": "كلمة مرور Wallabag",
"form.integration.wallabag_username": "اسم مستخدم Wallabag",
"form.integration.wallabag_tags": "وسوم Wallabag",
"form.integration.webhook_activate": "تفعيل Webhooks",
"form.integration.webhook_secret": "سر Webhooks",
"form.integration.webhook_url": "رابط Webhook الافتراضي",
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "قراءة المقالات عن طريق فتح الروابط الخارجية",
"form.prefs.label.categories_sorting_order": "فرز الفئات",
"form.prefs.label.cjk_reading_speed": "سرعة القراءة للغات الصينية والكورية واليابانية (حرف في الدقيقة)",
"form.prefs.label.custom_css": "CSS مخصص",
"form.prefs.label.custom_js": "JavaScript مخصص",
"form.prefs.label.default_home_page": "الصفحة الرئيسية الافتراضية",
"form.prefs.label.default_reading_speed": "سرعة القراءة للغات الأخرى (كلمة في الدقيقة)",
"form.prefs.label.display_mode": "وضع العرض (Progressive Web App - PWA)",
"form.prefs.label.entries_per_page": "عدد المقالات في الصفحة",
"form.prefs.label.entry_order": "عمود فرز المقالات",
"form.prefs.label.entry_sorting": "فرز المقالات",
"form.prefs.label.entry_swipe": "تفعيل التمرير للمقالات على الشاشات التي تعمل باللمس",
"form.prefs.label.external_font_hosts": "مضيفو الخطوط الخارجية",
"form.prefs.label.gesture_nav": "إيماءة للتنقل بين المقالات",
"form.prefs.label.keyboard_shortcuts": "تفعيل اختصارات لوحة المفاتيح",
"form.prefs.label.language": "اللغة",
"form.prefs.label.mark_read_manually": "تحديد المقالات كمقروءة يدوياً",
"form.prefs.label.mark_read_on_media_completion": "حدد كمقروء فقط عندما يصل تشغيل الصوت/الفيديو إلى 90% من الاكتمال",
"form.prefs.label.mark_read_on_view": "تحديد المقالات تلقائياً كمقروءة عند عرضها",
"form.prefs.label.mark_read_on_view_or_media_completion": "حدد المقالات كمقروءة عند عرضها. بالنسبة للصوت/الفيديو، حدد كمقروء عند اكتمال 90%",
"form.prefs.label.media_playback_rate": "سرعة تشغيل الصوت/فيديو",
"form.prefs.label.open_external_links_in_new_tab": "فتح الروابط الخارجية في تبويب جديد (يضيف target=\"_blank\" للروابط)",
"form.prefs.label.show_reading_time": "إظهار الوقت المقدر للقراءة للمقالات",
"form.prefs.label.theme": "السمة",
"form.prefs.label.timezone": "المنطقة الزمنية",
"form.prefs.select.alphabetical": "أبجدي",
"form.prefs.select.browser": "المتصفح",
"form.prefs.select.created_time": "وقت إنشاء المقال",
"form.prefs.select.fullscreen": "ملء الشاشة",
"form.prefs.select.minimal_ui": "الحد الأدنى",
"form.prefs.select.none": "بدون",
"form.prefs.select.older_first": "المقالات القديمة أولاً",
"form.prefs.select.publish_time": "وقت نشر المقال",
"form.prefs.select.recent_first": "المقالات الحديثة أولاً",
"form.prefs.select.standalone": "مستقل",
"form.prefs.select.swipe": "تمرير سريع",
"form.prefs.select.tap": "نقر مزدوج",
"form.prefs.select.unread_count": "عدد غير المقروءة",
"form.submit.loading": "جارٍ التحميل...",
"form.submit.saving": "جارٍ الحفظ...",
"form.user.label.admin": "مدير",
"form.user.label.confirmation": "تأكيد كلمة المرور",
"form.user.label.password": "كلمة المرور",
"form.user.label.username": "اسم المستخدم",
"menu.about": "حول",
"menu.add_feed": "إضافة مصدر",
"menu.add_user": "إضافة مستخدم",
"menu.api_keys": "مفاتيح API",
"menu.categories": "الفئات",
"menu.create_api_key": "إنشاء مفتاح API جديد",
"menu.create_category": "إنشاء فئة",
"menu.edit_category": "تعديل",
"menu.edit_feed": "تعديل",
"menu.export": "تصدير",
"menu.feed_entries": "المقالات",
"menu.feeds": "المصادر",
"menu.flush_history": "مسح السجل",
"menu.history": "السجل",
"menu.home_page": "الصفحة الرئيسية",
"menu.import": "استيراد",
"menu.integrations": "خدمات مرتبطة",
"menu.logout": "تسجيل الخروج",
"menu.mark_all_as_read": "تحديد الكل كمقروء",
"menu.mark_page_as_read": "تحديد هذه الصفحة كمقروءة",
"menu.preferences": "التفضيلات",
"menu.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
"menu.refresh_feed": "تحديث",
"menu.search": "بحث",
"menu.sessions": "الجلسات",
"menu.settings": "الإعدادات",
"menu.shared_entries": "المقالات المشاركة",
"menu.show_all_entries": "إظهار كل المقالات",
"menu.show_only_starred_entries": "إظهار المقالات المفضلة فقط",
"menu.show_only_unread_entries": "إظهار المقالات غير المقروءة فقط",
"menu.starred": "المفضلة",
"menu.title": "القائمة",
"menu.unread": "غير مقروء",
"menu.users": "المستخدمون",
"page.about.author": "المؤلف:",
"page.about.build_date": "تاريخ البناء:",
"page.about.credits": "شكر وتقدير",
"page.about.db_usage": "حجم قاعدة البيانات:",
"page.about.git_commit": "التزام Git:",
"page.about.global_config_options": "خيارات التكوين العامة",
"page.about.go_version": "إصدار Go:",
"page.about.license": "الرخصة:",
"page.about.postgres_version": "إصدار Postgres:",
"page.about.title": "حول",
"page.about.version": "الإصدار:",
"page.add_feed.choose_feed": "اختر مصدراً",
"page.add_feed.label.url": "الرابط",
"page.add_feed.legend.advanced_options": "خيارات متقدمة",
"page.add_feed.no_category": "لا توجد فئة. يجب أن يكون لديك فئة واحدة على الأقل.",
"page.add_feed.submit": "البحث عن مصدر",
"page.add_feed.title": "مصدر جديد",
"page.api_keys.never_used": "لم يُستخدم أبداً",
"page.api_keys.table.actions": "الإجراءات",
"page.api_keys.table.created_at": "تاريخ الإنشاء",
"page.api_keys.table.description": "الوصف",
"page.api_keys.table.last_used_at": "آخر استخدام",
"page.api_keys.table.token": "الرمز",
"page.api_keys.title": "مفاتيح API",
"page.categories.entries": "المقالات",
"page.categories.feed_count": [
"لا يوجد مصادر.",
"يوجد مصدر واحد.",
"يوجد مصدران.",
"يوجد %d مصادر.",
"يوجد %d مصدراً.",
"يوجد %d مصدراً."
],
"page.categories.feeds": "المصادر",
"page.categories.no_feed": "لا يوجد مصدر.",
"page.categories.title": "الفئات",
"page.categories_count": [
"%d فئة",
"فئة واحدة",
"فئتان",
"%d فئات",
"%d فئة",
"%d فئة"
],
"page.category_label": "الفئة: %s",
"page.edit_category.title": "تعديل الفئة: %s",
"page.edit_feed.etag_header": "رأس ETag:",
"page.edit_feed.last_check": "آخر فحص:",
"page.edit_feed.last_modified_header": "رأس LastModified:",
"page.edit_feed.last_parsing_error": "آخر خطأ تحليل",
"page.edit_feed.no_header": "لا يوجد",
"page.edit_feed.title": "تعديل المصدر: %s",
"page.edit_user.title": "تعديل المستخدم: %s",
"page.entry.attachments": "مرفقات",
"page.feeds.error_count": [
"%d خطأ",
"خطأ واحد",
"خطآن",
"%d أخطاء",
"%d خطأً",
"%d خطأً"
],
"page.feeds.last_check": "آخر فحص:",
"page.feeds.next_check": "الفحص التالي:",
"page.feeds.read_counter": "عدد المقالات المقروءة",
"page.feeds.title": "المصادر",
"page.footer.elevator": "العودة للأعلى",
"page.history.title": "السجل",
"page.import.title": "استيراد",
"page.integration.bookmarklet": "أداة الإشارة المرجعية (Bookmarklet)",
"page.integration.bookmarklet.help": "يتيح لك هذا الرابط الخاص الاشتراك في أي موقع ويب مباشرةً باستخدام إشارة مرجعية في متصفح الويب الخاص بك.",
"page.integration.bookmarklet.instructions": "اسحب وأفلت هذا الرابط إلى إشاراتك المرجعية.",
"page.integration.bookmarklet.name": "إضافة إلى Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "نقطة نهاية API",
"page.integration.miniflux_api_password": "كلمة المرور",
"page.integration.miniflux_api_password_value": "كلمة مرور حسابك",
"page.integration.miniflux_api_username": "اسم المستخدم",
"page.integrations.title": "خدمات مرتبطة",
"page.keyboard_shortcuts.close_modal": "إغلاق النافذة المنبثقة",
"page.keyboard_shortcuts.download_content": "تحميل المحتوى الأصلي",
"page.keyboard_shortcuts.go_to_bottom_item": "الذهاب إلى آخر عنصر",
"page.keyboard_shortcuts.go_to_categories": "الذهاب إلى الفئات",
"page.keyboard_shortcuts.go_to_feed": "الذهاب إلى المصدر",
"page.keyboard_shortcuts.go_to_feeds": "الذهاب إلى المصادر",
"page.keyboard_shortcuts.go_to_history": "الذهاب إلى السجل",
"page.keyboard_shortcuts.go_to_next_item": "الذهاب إلى العنصر التالي",
"page.keyboard_shortcuts.go_to_next_page": "الذهاب إلى الصفحة التالية",
"page.keyboard_shortcuts.go_to_previous_item": "الذهاب إلى العنصر السابق",
"page.keyboard_shortcuts.go_to_previous_page": "الذهاب إلى الصفحة السابقة",
"page.keyboard_shortcuts.go_to_search": "التركيز على نموذج البحث",
"page.keyboard_shortcuts.go_to_settings": "الذهاب إلى الإعدادات",
"page.keyboard_shortcuts.go_to_starred": "الذهاب إلى المفضلة",
"page.keyboard_shortcuts.go_to_top_item": "الذهاب إلى أعلى عنصر",
"page.keyboard_shortcuts.go_to_unread": "الذهاب إلى غير المقروءة",
"page.keyboard_shortcuts.mark_page_as_read": "تحديد الصفحة الحالية كمقروءة",
"page.keyboard_shortcuts.open_comments": "فتح رابط التعليقات",
"page.keyboard_shortcuts.open_comments_same_window": "فتح رابط التعليقات في التبويب الحالي",
"page.keyboard_shortcuts.open_item": "فتح العنصر المحدد",
"page.keyboard_shortcuts.open_original": "فتح الرابط الأصلي",
"page.keyboard_shortcuts.open_original_same_window": "فتح الرابط الأصلي في التبويب الحالي",
"page.keyboard_shortcuts.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
"page.keyboard_shortcuts.remove_feed": "حذف هذا المصدر",
"page.keyboard_shortcuts.save_article": "حفظ المقال",
"page.keyboard_shortcuts.scroll_item_to_top": "تمرير العنصر إلى الأعلى",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح",
"page.keyboard_shortcuts.subtitle.actions": "الإجراءات",
"page.keyboard_shortcuts.subtitle.items": "التنقل بين العناصر",
"page.keyboard_shortcuts.subtitle.pages": "التنقل بين الصفحات",
"page.keyboard_shortcuts.subtitle.sections": "التنقل بين الأقسام",
"page.keyboard_shortcuts.title": "اختصارات لوحة المفاتيح",
"page.keyboard_shortcuts.toggle_star_status": "تبديل المفضلة",
"page.keyboard_shortcuts.toggle_entry_attachments": "تبديل فتح/إغلاق مرفقات المقال",
"page.keyboard_shortcuts.toggle_read_status_next": "تبديل مقروء/غير مقروء، التركيز على التالي",
"page.keyboard_shortcuts.toggle_read_status_prev": "تبديل مقروء/غير مقروء، التركيز على السابق",
"page.login.google_signin": "تسجيل الدخول باستخدام Google",
"page.login.oidc_signin": "تسجيل الدخول باستخدام %s",
"page.login.title": "تسجيل الدخول",
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
"page.new_api_key.title": "مفتاح API جديد",
"page.new_category.title": "فئة جديدة",
"page.new_user.title": "مستخدم جديد",
"page.offline.message": "أنت غير متصل بالإنترنت",
"page.offline.refresh_page": "حاول تحديث الصفحة",
"page.offline.title": "وضع عدم الاتصال",
"page.read_entry_count": [
"%d مقال مقروء",
"مقال واحد مقروء",
"مقالان مقروءان",
"%d مقالات مقروءة",
"%d مقالاً مقروءاً",
"%d مقالاً مقروءاً"
],
"page.search.title": "نتائج البحث",
"page.sessions.table.actions": "الإجراءات",
"page.sessions.table.current_session": "الجلسة الحالية",
"page.sessions.table.date": "التاريخ",
"page.sessions.table.ip": "عنوان IP",
"page.sessions.table.user_agent": "وكيل المستخدم (User Agent)",
"page.sessions.title": "الجلسات",
"page.settings.link_google_account": "ربط حسابي في Google",
"page.settings.link_oidc_account": "ربط حسابي في %s",
"page.settings.title": "الإعدادات",
"page.settings.unlink_google_account": "فك ارتباط حسابي في Google",
"page.settings.unlink_oidc_account": "فك ارتباط حسابي في %s",
"page.settings.webauthn.actions": "الإجراءات",
"page.settings.webauthn.added_on": "أضيف في",
"page.settings.webauthn.delete": [
"إزالة %d مفتاح مرور",
"إزالة مفتاح مرور واحد",
"إزالة مفتاحي مرور",
"إزالة %d مفاتيح مرور",
"إزالة %d مفتاح مرور",
"إزالة %d مفتاح مرور"
],
"page.settings.webauthn.last_seen_on": "آخر استخدام",
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
"page.settings.webauthn.passkeys": "مفاتيح المرور",
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
"page.shared_entries.title": "المقالات المشاركة",
"page.shared_entries_count": [
"%d مقال مشترك",
"مقال واحد مشترك",
"مقالان مشتركان",
"%d مقالات مشتركة",
"%d مقالاً مشتركاً",
"%d مقالاً مشتركاً"
],
"page.starred.title": "المفضلة",
"page.starred_entry_count": [
"%d مقال مفضل",
"مقال واحد مفضل",
"مقالان مفضلان",
"%d مقالات مفضلة",
"%d مقالاً مفضلاً",
"%d مقالاً مفضلاً"
],
"page.total_entry_count": [
"%d مقال في الإجمالي",
"مقال واحد في الإجمالي",
"مقالان في الإجمالي",
"%d مقالات في الإجمالي",
"%d مقالاً في الإجمالي",
"%d مقالاً في الإجمالي"
],
"page.unread.title": "غير المقروءة",
"page.unread_entry_count": [
"%d مقال غير مقروء",
"مقال واحد غير مقروء",
"مقالان غير مقروءين",
"%d مقالات غير مقروءة",
"%d مقالاً غير مقروء",
"%d مقالاً غير مقروء"
],
"page.users.actions": "الإجراءات",
"page.users.admin.no": "لا",
"page.users.admin.yes": "نعم",
"page.users.is_admin": "مدير",
"page.users.last_login": "آخر دخول",
"page.users.never_logged": "أبداً",
"page.users.title": "المستخدمون",
"page.users.username": "اسم المستخدم",
"page.webauthn_rename.title": "إعادة تسمية مفتاح المرور",
"pagination.first": "الأول",
"pagination.last": "الأخير",
"pagination.next": "التالي",
"pagination.previous": "السابق",
"search.label": "بحث",
"search.placeholder": "بحث...",
"search.submit": "بحث",
"skip_to_content": "تخطي إلى المحتوى",
"time_elapsed.days": [
"منذ %d يوم",
"منذ يوم واحد",
"منذ يومين",
"منذ %d أيام",
"منذ %d يوماً",
"منذ %d يوماً"
],
"time_elapsed.hours": [
"منذ %d ساعة",
"منذ ساعة واحدة",
"منذ ساعتين",
"منذ %d ساعات",
"منذ %d ساعة",
"منذ %d ساعة"
],
"time_elapsed.minutes": [
"منذ %d دقيقة",
"منذ دقيقة واحدة",
"منذ دقيقتين",
"منذ %d دقائق",
"منذ %d دقيقة",
"منذ %d دقيقة"
],
"time_elapsed.months": [
"منذ %d شهر",
"منذ شهر واحد",
"منذ شهرين",
"منذ %d أشهر",
"منذ %d شهراً",
"منذ %d شهراً"
],
"time_elapsed.not_yet": "ليس بعد",
"time_elapsed.now": "الآن",
"time_elapsed.weeks": [
"منذ %d أسبوع",
"منذ أسبوع واحد",
"منذ أسبوعين",
"منذ %d أسابيع",
"منذ %d أسبوعاً",
"منذ %d أسبوعاً"
],
"time_elapsed.years": [
"منذ %d سنة",
"منذ سنة واحدة",
"منذ سنتين",
"منذ %d سنوات",
"منذ %d سنة",
"منذ %d سنة"
],
"time_elapsed.yesterday": "أمس",
"tooltip.keyboard_shortcuts": "اختصار لوحة المفاتيح: %s",
"tooltip.logged_user": "تم تسجيل الدخول باسم %s"
}
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Beschreibung",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Κατηγορία",
"form.feed.label.cookie": "Ορισμός Cookies",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Category",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disabled": "Do not refresh this feed",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descripción",
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
"form.feed.label.disabled": "No actualice este feed",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Aseta evästeet",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Kuvaus",
"form.feed.label.disable_http2": "Poista HTTP/2 käytöstä sormenjälkien välttämiseksi",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Catégorie",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Description",
"form.feed.label.disable_http2": "Désactiver HTTP/2",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
+635
View File
@@ -0,0 +1,635 @@
{
"action.cancel": "cancelar",
"action.download": "Descargar",
"action.edit": "Editar",
"action.home_screen": "Engadir á pantalla de inicio",
"action.import": "Importar",
"action.login": "Acceso",
"action.or": "ou",
"action.remove": "Retirar",
"action.remove_feed": "Retirar esta canle",
"action.save": "Gardar",
"action.subscribe": "Dar de baixa",
"action.update": "Actualizar",
"alert.account_linked": "Conectouse a túa conta externa!",
"alert.account_unlinked": "Desconectouse a túa conta externa!",
"alert.background_feed_refresh": "Estanse actualizando en segundo plano todas as canles. Podes continuar usando Miniflux mentras se realiza a actualización.",
"alert.feed_error": "Hai un problema con esta canle.",
"alert.no_starred": "Non hai artigos con estrela.",
"alert.no_category": "Non hai categorías.",
"alert.no_category_entry": "Non hai artigos nesta categoría.",
"alert.no_feed": "Non engadiches ningunha canle.",
"alert.no_feed_entry": "Non hai artigos para esta canle.",
"alert.no_feed_in_category": "Non hai canles nesta categoría.",
"alert.no_history": "Por agora non hai historial.",
"alert.no_search_result": "Non hai resultados para esta busca.",
"alert.no_shared_entry": "Non hai artigos compartidos.",
"alert.no_tag_entry": "Non hai artigos con esta etiqueta.",
"alert.no_unread_entry": "Non hai artigos sen ler.",
"alert.no_user": "Es a única conta usuaria.",
"alert.prefs_saved": "Gardáronse as preferencias!",
"alert.too_many_feeds_refresh": [
"Intentaches demasiadas actualizacións da canle. Agarda %d minuto antes de volver intentalo.",
"Intentaches demasiadas actualizacións da canle. Agarda %d minutos antes de volver intentalo."
],
"confirm.loading": "En proceso…",
"confirm.no": "non",
"confirm.question": "Tes certeza?",
"confirm.question.refresh": "Tes certeza de querer forzar a actualización?",
"confirm.yes": "si",
"enclosure_media_controls.seek": "Avanzar:",
"enclosure_media_controls.seek.title": "Avanzar %s segundos",
"enclosure_media_controls.speed": "Velocidade:",
"enclosure_media_controls.speed.faster": "Máis rápido",
"enclosure_media_controls.speed.faster.title": "Máis rápido %sx",
"enclosure_media_controls.speed.reset": "Restablecer",
"enclosure_media_controls.speed.reset.title": "Restablecer velocidade a 1x",
"enclosure_media_controls.speed.slower": "Máis lento",
"enclosure_media_controls.speed.slower.title": "Máis lento %sx",
"entry.starred.toast.off": "Sen estrela",
"entry.starred.toast.on": "Con estrela",
"entry.starred.toggle.off": "Retirar estrela",
"entry.starred.toggle.on": "Pór estrela",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.estimated_reading_time": [
"%d minuto de lectura",
"%d minutos de lectura"
],
"entry.external_link.label": "Ligazón externa",
"entry.save.completed": "Feito!",
"entry.save.label": "Gardar",
"entry.save.title": "Gardar este artigo",
"entry.save.toast.completed": "Gardouse o artigo",
"entry.scraper.completed": "Feito!",
"entry.scraper.label": "Descargar",
"entry.scraper.title": "Obter contido orixinal",
"entry.share.label": "Compartir",
"entry.share.title": "Compartir este artigo",
"entry.shared_entry.label": "Compartir",
"entry.shared_entry.title": "Abrir ligazón pública",
"entry.state.loading": "Cargando…",
"entry.state.saving": "Gardando…",
"entry.status.mark_as_read": "Marcar como lido",
"entry.status.mark_as_unread": "Marcar como non lido",
"entry.status.title": "Cambiar estado do artigo",
"entry.status.toast.read": "Marcado como lido",
"entry.status.toast.unread": "Marcado como non lido",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar %d etiqueta máis",
"Mostrar %d etiquetas máis"
],
"entry.unshare.label": "Non compartir",
"error.api_key_already_exists": "Xa existe esta clave da API.",
"error.bad_credentials": "Credenciais incorrectas.",
"error.category_already_exists": "Xa existe a categoría.",
"error.category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
"error.database_error": "Erro na base de datos: %v.",
"error.different_passwords": "Os contrasinais non coinciden.",
"error.duplicate_fever_username": "Xa hai alguén con ese identificador en Fever!",
"error.duplicate_googlereader_username": "Xa hai alguén con ese identificador en Google Reader!",
"error.linktaco_missing_required_fields": "Requírese LinkTaco API Token e Organization Slug",
"error.duplicate_linked_account": "Xa hai alguén asociado con este provedor!",
"error.duplicated_feed": "Xa existe a canle.",
"error.empty_file": "O ficheiro está baleiro.",
"error.entries_per_page_invalid": "O número de artigos por páxina non é válido.",
"error.feed_already_exists": "Xa existe a canle.",
"error.feed_category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
"error.feed_format_not_detected": "Non se puido detectar o formato da canle: %v.",
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueo non é válida.",
"error.feed_invalid_keeplist_rule": "A regra da lista a manter non é válida.",
"error.feed_mandatory_fields": "É obrigatorio engadir un URL para a categoría.",
"error.feed_not_found": "A canle non existe ou non pertence a esta usuaria.",
"error.feed_title_not_empty": "O título da canle non pode quedar baleiro.",
"error.feed_url_not_empty": "O URL da canle non pode quedar baleiro.",
"error.fields_mandatory": "Son obrigatorios todos os campos.",
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
"error.http_client_error": "Erro HTTP no cliente: %v.",
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
"error.http_gateway_timeout": "O sitio web non está dispoñible debido a un erro de caducidade na pasarela. O problema non está en Miniflux. Volve a intentalo máis tarde.",
"error.http_internal_server_error": "O sitio web non está dispoñible debido a un fallo no servidor. O problema non está en Miniflux. Volve a intentalo máis tarde.",
"error.http_not_authorized": "Non hai autorización para acceder a este sitio web. Podería deberse a unhas credenciais non válidas.",
"error.http_resource_not_found": "Non se atopa o recurso solicitado. Por favor, comproba o URL.",
"error.http_response_too_large": "A resposta HTTP é demasiado grande. Podes aumentar o límite da resposta HTTP nos axustes xerais (require reinicio do servidor).",
"error.http_service_unavailable": "O sitio web non está dispoñible neste momento debido a un erro interno do servidor. O problema non está en Miniflux, inténtao máis tarde.",
"error.http_too_many_requests": "Miniflux xerou demasiadas peticións a este servidor. Inténtao máis tarde ou cambia a configuración da aplicación.",
"error.http_unexpected_status_code": "O sitio web non está dispoñible neste momento debido a un código de estado HTTP non agardado: %d. O problema non está en Miniflux, inténtao máis tarde.",
"error.invalid_categories_sorting_order": "Orde por categorías non válido.",
"error.invalid_default_home_page": "Páxina de inicio predeterminada non válida!",
"error.invalid_display_mode": "Modo para mostrar a aplicación web non válido.",
"error.invalid_entry_direction": "Dirección da entrada non válida.",
"error.invalid_entry_order": "Orde da entrada non válida.",
"error.invalid_feed_proxy_url": "URL do mandatario non válido.",
"error.invalid_feed_url": "URL da canle non válido.",
"error.invalid_gesture_nav": "Xesto de navegación non válido.",
"error.invalid_language": "Idioma non válido.",
"error.invalid_site_url": "URL da web non válido.",
"error.invalid_theme": "Decorado non válido.",
"error.invalid_timezone": "Zona horaria non válida.",
"error.network_operation": "Miniflux non pode acadar esta web por mor dun erro na rede: %v.",
"error.network_timeout": "Esta web é demasiado lenta e caducou a petición: %v",
"error.password_min_length": "O contrasinal ten que ter 6 caracteres polo menos.",
"error.proxy_url_not_empty": "O URL do mandatario non pode quedar baleiro.",
"error.settings_block_rule_fieldname_invalid": "Regra do Bloque non válida: á regra #%d fáltalle un nome de campo válido (Opcións: %s)",
"error.settings_block_rule_invalid_regex": "Regra do Bloque non válida: o patrón da regra #%d non é unha expresión regex válida",
"error.settings_block_rule_regex_required": "Regra do Bloque non válida: non se proporcionou o patrón da regra #%d",
"error.settings_block_rule_separator_required": "Regra do Bloque non válida:: o patrón da regra #%d require estar separado por un '='",
"error.settings_invalid_domain_list": "Lista de dominios non válida. Proporciona unha lista de dominios separados por espazos.",
"error.settings_keep_rule_fieldname_invalid": "Regra para Manter non válida: a regra #%d non ten un nome de campo válido (Opcións: %s)",
"error.settings_keep_rule_invalid_regex": "Regra para Manter non válida: o patrón da regra #%d non é unha expresión regex válida",
"error.settings_keep_rule_regex_required": "Regra para Manter non válida: non se proporcionou o patrón para #%d",
"error.settings_keep_rule_separator_required": "Regra para Manter non válida: o patrón da regra #%d ten que estar separado por un '='",
"error.settings_mandatory_fields": "O identificador, decorado, idioma e zona horaria son campos obrigatorios.",
"error.settings_media_playback_rate_range": "A velocidade de reprodución está fóra do rango admitido",
"error.settings_reading_speed_is_positive": "A velocidade de lectura ten que ser un número enteiro positivo.",
"error.site_url_not_empty": "O URL da web non pode estar baleiro.",
"error.subscription_not_found": "Non se atopou ningunha canle.",
"error.title_required": "O título é obrigatorio.",
"error.tls_error": "Erro TLS: %q. Podes desactivar a verificación TLS nos axustes da canle se queres.",
"error.unable_to_create_api_key": "Non se puido crear a clave da API.",
"error.unable_to_create_category": "Non se puido crear a categoría.",
"error.unable_to_create_user": "Non se puido crear a conta.",
"error.unable_to_detect_rssbridge": "Non se detectou a canle a usar RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Non se puido procesar a canle: %v.",
"error.unable_to_update_category": "Non se puido actualizar a categoría.",
"error.unable_to_update_feed": "Non se puido actualizar a canle.",
"error.unable_to_update_user": "Non se puido actualizar a usuaria.",
"error.unlink_account_without_password": "Tes que crear un contrasinal, se non non poderás volver acceder.",
"error.user_already_exists": "Xa existe esta usuaria.",
"error.user_mandatory_fields": "O identificador é obrigatorio.",
"form.api_key.label.description": "Etiqueta da Clave da API",
"form.category.hide_globally": "Ocultar entradas na lista global de non lidos",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "Xeral",
"form.feed.fieldset.integration": "Servizos de Terceiras Partes",
"form.feed.fieldset.network_settings": "Axustes da rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Establecer rastros",
"form.feed.label.crawler": "Obter contido orixinal",
"form.feed.label.description": "Descrición",
"form.feed.label.disable_http2": "Desactivar HTTP/2 para evitar «fingerprinting»",
"form.feed.label.disabled": "Non actualizar esta canle",
"form.feed.label.feed_password": "Contrasinal para a canle",
"form.feed.label.feed_url": "URL da canle",
"form.feed.label.feed_username": "Identificador para a canle",
"form.feed.label.fetch_via_proxy": "Usar o mandatario configurado a nivel da aplicación",
"form.feed.label.hide_globally": "Ocultar entradas na lista global de non lidos",
"form.feed.label.ignore_entry_updates": "Ignorar actualizacións da entrada",
"form.feed.label.ignore_http_cache": "Ignorar memoria tobo HTTP",
"form.feed.label.keep_filter_entry_rules": "Regra para Entradas permitidas",
"form.feed.label.keeplist_rules": "Filtros para Manter baseados en RegEx",
"form.feed.label.no_media_player": "Sen reprodutor (son/vídeo)",
"form.feed.label.ntfy_activate": "Enviar novidades a Ntfy",
"form.feed.label.ntfy_default_priority": "Prioridade predeterminada Ntfy",
"form.feed.label.ntfy_high_priority": "Alta prioridade Ntfy",
"form.feed.label.ntfy_low_priority": "Baix prioridade Ntfy",
"form.feed.label.ntfy_max_priority": "Prioridade máx. Ntfy",
"form.feed.label.ntfy_min_priority": "Prioridade mín. Ntfy",
"form.feed.label.ntfy_priority": "Prioridade en Ntfy",
"form.feed.label.ntfy_topic": "Tema en Ntfy (optativo)",
"form.feed.label.proxy_url": "URL do mandatario",
"form.feed.label.pushover_activate": "Enviar novidades a Pushover",
"form.feed.label.pushover_default_priority": "Prioridade predeterminada",
"form.feed.label.pushover_high_priority": "Alta prioridade",
"form.feed.label.pushover_low_priority": "Baixa prioridade",
"form.feed.label.pushover_max_priority": "Prioridade máx.",
"form.feed.label.pushover_min_priority": "Prioridade mín.",
"form.feed.label.pushover_priority": "Prioridade da mensaxe Pushover",
"form.feed.label.rewrite_rules": "Regras de Reescritura do contido",
"form.feed.label.scraper_rules": "Regras ao obter contido",
"form.feed.label.site_url": "URL do sitio",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Regras de rescritura URL",
"form.feed.label.user_agent": "Sobrescribir User Agent predeterminado",
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "Ficheiro OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar entradas a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
"form.integration.apprise_url": "URL de Apprise API",
"form.integration.betula_activate": "Gardar entradas en Betula",
"form.integration.betula_token": "Token de Betula",
"form.integration.betula_url": "URL do servidor Betula",
"form.integration.cubox_activate": "Gardar entradas en Cubox",
"form.integration.cubox_api_link": "Ligazón a Cubox API",
"form.integration.discord_activate": "Enviar entradas a Discord",
"form.integration.discord_webhook_link": "Ligazón a Discord Webhook",
"form.integration.espial_activate": "Enviar entradas a Espial",
"form.integration.espial_api_key": "Clave de Espial API",
"form.integration.espial_endpoint": "Acceso na Espial API",
"form.integration.espial_tags": "Etiquetas Espial",
"form.integration.fever_activate": "Activar Fever API",
"form.integration.fever_endpoint": "Punto de acceso da Fever API:",
"form.integration.fever_password": "Contrasinal Fever",
"form.integration.fever_username": "Identificador Fever",
"form.integration.googlereader_activate": "Activar API Google Reader",
"form.integration.googlereader_endpoint": "Punto de acceso de Google Reader API:",
"form.integration.googlereader_password": "Contrasinal Google Reader",
"form.integration.googlereader_username": "Identificador Google Reader",
"form.integration.instapaper_activate": "Gardar entradas en Instapaper",
"form.integration.instapaper_password": "Contrasinal Instapaper",
"form.integration.instapaper_username": "Identificador Instapaper",
"form.integration.karakeep_activate": "Gardar entradas en Karakeep",
"form.integration.karakeep_api_key": "Clave de Karakeep API",
"form.integration.karakeep_url": "Punto de acceso da Karakeep API",
"form.integration.karakeep_tags": "Etiquetas Karakeep",
"form.integration.linkace_activate": "Gardar entradas en LinkAce",
"form.integration.linkace_api_key": "Clave de LinkAce API",
"form.integration.linkace_check_disabled": "Desactivar comprobación de ligazóns",
"form.integration.linkace_endpoint": "Punto de acceso da LinkAce API",
"form.integration.linkace_is_private": "Marcar ligazón como privada",
"form.integration.linkace_tags": "Etiquetas LinkAce",
"form.integration.linkding_activate": "Gardar entradas en Linkding",
"form.integration.linkding_api_key": "Clave da Linkding API",
"form.integration.linkding_bookmark": "Marcar marcador como non lido",
"form.integration.linkding_endpoint": "Punto de acceso da Linkding API",
"form.integration.linkding_tags": "Etiquetas Linkding",
"form.integration.linktaco_activate": "Gardar entradas en LinkTaco",
"form.integration.linktaco_api_token": "Token da API de LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtén o token de acceso persoal en",
"form.integration.linktaco_org_slug": "Sobrenome da Organización",
"form.integration.linktaco_tags": "Etiquetas (máx 10, separadas por comas)",
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
"form.integration.linktaco_visibility": "Visibilidade",
"form.integration.linktaco_visibility_public": "Pública",
"form.integration.linktaco_visibility_private": "Privada",
"form.integration.linktaco_visibility_hint": "A visibilidade privada require unha conta de pago de LinkTaco",
"form.integration.linkwarden_activate": "Gardar entradas en Linkwarden",
"form.integration.linkwarden_api_key": "Clave da Linkwarden API",
"form.integration.linkwarden_endpoint": "URL Base de Linkwarden",
"form.integration.linkwarden_collection_id": "ID da colección Linkwarden",
"form.integration.matrix_bot_activate": "Enviar entradas a Matrix",
"form.integration.matrix_bot_chat_id": "ID da Sala Matrix",
"form.integration.matrix_bot_password": "Contrasinal da usuaria Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_user": "Identificador en Matrix",
"form.integration.notion_activate": "Gardar entradas en Notion",
"form.integration.notion_page_id": "ID da páxina Notion",
"form.integration.notion_token": "Token secreto para Notion",
"form.integration.ntfy_activate": "Enviar entradas a ntfy",
"form.integration.ntfy_api_token": "Token da Ntfy API (optativo)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optativo)",
"form.integration.ntfy_internal_links": "Usar ligazóns internas ao premer (optativo)",
"form.integration.ntfy_password": "Contrasinal Ntfy (optativo)",
"form.integration.ntfy_topic": "Tema en Ntfy (úsase por defecto se non o establece a canle)",
"form.integration.ntfy_url": "Ntfy URL (optativo, predeterminado ntfy.sh)",
"form.integration.ntfy_username": "Contrasinal Ntfy (optativo)",
"form.integration.nunux_keeper_activate": "Gardar entradas en Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave da Nunux Keeper API",
"form.integration.nunux_keeper_endpoint": "Punto de acceso Nunux Keeper API",
"form.integration.omnivore_activate": "Gardar entradas en Omnivore",
"form.integration.omnivore_api_key": "Clave da Omnivore API",
"form.integration.omnivore_url": "Punto de acceso de Omnivore API",
"form.integration.pinboard_activate": "Gardar entradas en Pinboard",
"form.integration.pinboard_bookmark": "Marcar marcador como non lido",
"form.integration.pinboard_tags": "Etiquetas Pinboard",
"form.integration.pinboard_token": "Token da Pinboard API",
"form.integration.pushover_activate": "Enviar entradas a Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (optativo)",
"form.integration.pushover_prefix": "Prefixo do URL Pushover (optativo)",
"form.integration.pushover_token": "Token da aplicación na Pushover API",
"form.integration.pushover_user": "Clave da usuaria Pushover",
"form.integration.raindrop_activate": "Gardar entradas en Raindrop",
"form.integration.raindrop_collection_id": "ID da colección",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "Token (de proba)",
"form.integration.readeck_activate": "Gardar entradas en Readeck",
"form.integration.readeck_api_key": "Clave da Readeck API",
"form.integration.readeck_endpoint": "URL de Readeck",
"form.integration.readeck_labels": "Etiquetas Readeck",
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obter o token de acceso a Readwise",
"form.integration.rssbridge_activate": "Comprobar RSS-Bridge ao engadir subscricións",
"form.integration.rssbridge_token": "Token de autenticación RSS-Bridge",
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
"form.integration.shaarli_activate": "Gardar artigos en Shaarli",
"form.integration.shaarli_api_secret": "Clave secreta da API Shaarli",
"form.integration.shaarli_endpoint": "Punto de acceso Shaarli",
"form.integration.shiori_activate": "Gardar artigos en Shiori",
"form.integration.shiori_endpoint": "Punto de acceso Shiori API",
"form.integration.shiori_password": "Contrasinal Shiori",
"form.integration.shiori_username": "Identificador Shiori",
"form.integration.slack_activate": "Enviar entradas a Slack",
"form.integration.slack_webhook_link": "Ligzón de Slack Webhook",
"form.integration.telegram_bot_activate": "Enviar novas entradas a parola Telegram",
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
"form.integration.telegram_bot_token": "Toke do Bot",
"form.integration.telegram_chat_id": "ID da parola",
"form.integration.telegram_topic_id": "ID do tema",
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.wallabag_password": "Contrasinal en Wallabag",
"form.integration.wallabag_username": "Identificador en Wallabag",
"form.integration.wallabag_tags": "Etiquetas Wallabag",
"form.integration.webhook_activate": "Activar Webhooks",
"form.integration.webhook_secret": "Clave secreta Webhooks",
"form.integration.webhook_url": "URL predeterminada Webhook",
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ler artigos abringo ligazóns externas",
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS persoal",
"form.prefs.label.custom_js": "JavaScript persoal",
"form.prefs.label.default_home_page": "Páxina de inicio predeterminada",
"form.prefs.label.default_reading_speed": "Velocidade de lectura para outros idiomas (palabras por minuto)",
"form.prefs.label.display_mode": "Disposición da interface Progressive Web App (PWA)",
"form.prefs.label.entries_per_page": "Entradas por páxina",
"form.prefs.label.entry_order": "Columna para orde das entradas",
"form.prefs.label.entry_sorting": "Orde das entradas",
"form.prefs.label.entry_swipe": "Activar o desprazamento de entradas en pantallas táctiles",
"form.prefs.label.external_font_hosts": "Servidores externos de tipografías",
"form.prefs.label.gesture_nav": "Xestos para moverse entre entradas",
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
"form.prefs.label.theme": "Decorado",
"form.prefs.label.timezone": "Zona horaria",
"form.prefs.select.alphabetical": "Alfabética",
"form.prefs.select.browser": "Navegador",
"form.prefs.select.created_time": "Hora de creación da entrada",
"form.prefs.select.fullscreen": "Pantalla completa",
"form.prefs.select.minimal_ui": "Mínima",
"form.prefs.select.none": "Ningunha",
"form.prefs.select.older_first": "Primeiro as antigas",
"form.prefs.select.publish_time": "Hora de publicación da entrada",
"form.prefs.select.recent_first": "Primeiro as recentes",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.swipe": "Desprazar",
"form.prefs.select.tap": "Doble toque",
"form.prefs.select.unread_count": "Conta de sen ler",
"form.submit.loading": "Cargando…",
"form.submit.saving": "Gardando…",
"form.user.label.admin": "Admin",
"form.user.label.confirmation": "Confirmar contrasinal",
"form.user.label.password": "Contrasinal",
"form.user.label.username": "Identificador",
"menu.about": "Sobre",
"menu.add_feed": "Engadir canle",
"menu.add_user": "Engadir usuaria",
"menu.api_keys": "Claves da API",
"menu.categories": "Categorías",
"menu.create_api_key": "Crear nova clave da API",
"menu.create_category": "Crear unha categoría",
"menu.edit_category": "Editar",
"menu.edit_feed": "Editar",
"menu.export": "Exportar",
"menu.feed_entries": "Entradas",
"menu.feeds": "Canles",
"menu.flush_history": "Eliminar historial",
"menu.history": "Historial",
"menu.home_page": "Páxina de inicio",
"menu.import": "Importar",
"menu.integrations": "Integracións",
"menu.logout": "Fechar sesión",
"menu.mark_all_as_read": "Marca todo como lido",
"menu.mark_page_as_read": "Marca esta páxina como lida",
"menu.preferences": "Preferencias",
"menu.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
"menu.refresh_feed": "Actualizar",
"menu.search": "Buscar",
"menu.sessions": "Sesións",
"menu.settings": "Axustes",
"menu.shared_entries": "Entradas compartidas",
"menu.show_all_entries": "Motrar todas as entradas",
"menu.show_only_starred_entries": "Mostrar só entradas con estrela",
"menu.show_only_unread_entries": "Mostrar só entradas sen ler",
"menu.starred": "Con estrela",
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.author": "Autoría:",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Configuración global",
"page.about.go_version": "Versión de Go:",
"page.about.license": "Licenza:",
"page.about.postgres_version": "Versión de Postgres:",
"page.about.title": "Sobre",
"page.about.version": "Versión:",
"page.add_feed.choose_feed": "Elixe unha canle",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Opcións avanzadas",
"page.add_feed.no_category": "Non hai categoría. Tes que ter polo menos unha categoría.",
"page.add_feed.submit": "Atopa unha canle",
"page.add_feed.title": "Nova canle",
"page.api_keys.never_used": "Nunca utilizado",
"page.api_keys.table.actions": "Accións",
"page.api_keys.table.created_at": "Data de creación",
"page.api_keys.table.description": "Descrición",
"page.api_keys.table.last_used_at": "Último uso",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Claves da API",
"page.categories.entries": "Entradas",
"page.categories.feed_count": [
"Hai %d canle.",
"Hai %d canles."
],
"page.categories.feeds": "Canles",
"page.categories.no_feed": "No hai canle.",
"page.categories.title": "Categorías",
"page.categories_count": [
"%d categoría",
"%d categorías"
],
"page.category_label": "Categoría: %s",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_feed.etag_header": "Cabeceira ETag:",
"page.edit_feed.last_check": "Última comprobación:",
"page.edit_feed.last_modified_header": "Cabeceira LastModified:",
"page.edit_feed.last_parsing_error": "Erro Last Parsing",
"page.edit_feed.no_header": "Ningún",
"page.edit_feed.title": "Editar canle: %s",
"page.edit_user.title": "Editar usuaria: %s",
"page.entry.attachments": "Anexos",
"page.feeds.error_count": [
"%d erro",
"%d erros"
],
"page.feeds.last_check": "Última comprobación:",
"page.feeds.next_check": "Próxima comprobación:",
"page.feeds.read_counter": "Número de entradas lidas",
"page.feeds.title": "Canles",
"page.footer.elevator": "Volver arriba",
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Esta é unha ligazón especial que che permite subscribirte a unha web directamente usando un marcador no teu navegador.",
"page.integration.bookmarklet.instructions": "Arrastra e solta esta ligazón nos teus marcadores.",
"page.integration.bookmarklet.name": "Engadir a Miniflux",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Punto de acceso da API",
"page.integration.miniflux_api_password": "Contrasinal",
"page.integration.miniflux_api_password_value": "Contrasinal da túa conta",
"page.integration.miniflux_api_username": "Identificador",
"page.integrations.title": "Integracións",
"page.keyboard_shortcuts.close_modal": "Fechar diálogo modal",
"page.keyboard_shortcuts.download_content": "Descargar contido orixinal",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir ao elemento de abaixo de todo",
"page.keyboard_shortcuts.go_to_categories": "Ir a categorías",
"page.keyboard_shortcuts.go_to_feed": "Ir á canle",
"page.keyboard_shortcuts.go_to_feeds": "Ir ás canles",
"page.keyboard_shortcuts.go_to_history": "Ir ao historial",
"page.keyboard_shortcuts.go_to_next_item": "Ir ao seguinte elemento",
"page.keyboard_shortcuts.go_to_next_page": "Ir á páxina seguinte",
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao elemento anterior",
"page.keyboard_shortcuts.go_to_previous_page": "Ir á páxina anterior",
"page.keyboard_shortcuts.go_to_search": "Pór o foco no formulario de busca",
"page.keyboard_shortcuts.go_to_settings": "Ir aos axustes",
"page.keyboard_shortcuts.go_to_starred": "Ir a artigos con estrela",
"page.keyboard_shortcuts.go_to_top_item": "Ir ao elemento de arriba de todo",
"page.keyboard_shortcuts.go_to_unread": "Ir aos non lidos",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar páxina actual como lida",
"page.keyboard_shortcuts.open_comments": "Abrir ligazón de comentarios",
"page.keyboard_shortcuts.open_comments_same_window": "Abrir ligazón de comentarios na pestana actual",
"page.keyboard_shortcuts.open_item": "Abrir elemento seleccionado",
"page.keyboard_shortcuts.open_original": "Abrir ligazón orixinal",
"page.keyboard_shortcuts.open_original_same_window": "Abrir ligazón orixinal na pestana actual",
"page.keyboard_shortcuts.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
"page.keyboard_shortcuts.remove_feed": "Retirar esta canle",
"page.keyboard_shortcuts.save_article": "Gardar entrada",
"page.keyboard_shortcuts.scroll_item_to_top": "Desprazar o elemento arriba de todo",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atallos do teclado",
"page.keyboard_shortcuts.subtitle.actions": "Accións",
"page.keyboard_shortcuts.subtitle.items": "Moverse polos elementos",
"page.keyboard_shortcuts.subtitle.pages": "Moverse polas páxinas",
"page.keyboard_shortcuts.subtitle.sections": "Moverse polas seccións",
"page.keyboard_shortcuts.title": "Atallos do teclado",
"page.keyboard_shortcuts.toggle_star_status": "Pór/quitar estrela",
"page.keyboard_shortcuts.toggle_entry_attachments": "Cambiar abrir/fechar anexos da entrada",
"page.keyboard_shortcuts.toggle_read_status_next": "Cambiar lido/non lido, foco na seguinte",
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambiar lido/non lido, foco na anterior",
"page.login.google_signin": "Acceder con Google",
"page.login.oidc_signin": "Acceder con %s",
"page.login.title": "Acceder",
"page.login.webauthn_login": "Acceso con clave de paso",
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha clave de seguridade. Non se require isto se estás a usar unha «Passkey» (credenciais descubribles).",
"page.new_api_key.title": "Nova clave da API",
"page.new_category.title": "Nova Categoría",
"page.new_user.title": "Nova Usuaria",
"page.offline.message": "Non tes conexión",
"page.offline.refresh_page": "Intenta actualizar a páxina",
"page.offline.title": "Modo sen conexión",
"page.read_entry_count": [
"%d entrada lida",
"%d entradas lidas"
],
"page.search.title": "Resultados da busca",
"page.sessions.table.actions": "Accións",
"page.sessions.table.current_session": "Sesión actual",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Enderezo IP",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Sesións",
"page.settings.link_google_account": "Ligar coa miña conta Google",
"page.settings.link_oidc_account": "Ligar coa miña conta %s",
"page.settings.title": "Axustes",
"page.settings.unlink_google_account": "Desligar da miña conta Google",
"page.settings.unlink_oidc_account": "Desligar da miña conta %s",
"page.settings.webauthn.actions": "Accións",
"page.settings.webauthn.added_on": "Engadida o",
"page.settings.webauthn.delete": [
"Retirar %d passkey",
"Retirar %d passkeys"
],
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da Passkey",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Rexistrar Passkey",
"page.settings.webauthn.register.error": "Non se puido rexistrar Passkey",
"page.shared_entries.title": "Entradas compartidas",
"page.shared_entries_count": [
"%d entrada compartida",
"%d entradas compartidas"
],
"page.starred.title": "Con estrela",
"page.starred_entry_count": [
"%d entrada con estrela",
"%d entradas con estrela"
],
"page.total_entry_count": [
"%d entrada en total",
"%d entradas en total"
],
"page.unread.title": "Sen ler",
"page.unread_entry_count": [
"%d entrada sen ler",
"%d entradas sen ler"
],
"page.users.actions": "Accións",
"page.users.admin.no": "Non",
"page.users.admin.yes": "Si",
"page.users.is_admin": "Administración",
"page.users.last_login": "Último acceso",
"page.users.never_logged": "Nunca",
"page.users.title": "Usuarias",
"page.users.username": "Identificador",
"page.webauthn_rename.title": "Cambiar nome a Passkey",
"pagination.first": "Primeiro",
"pagination.last": "Último",
"pagination.next": "Seguinte",
"pagination.previous": "Anterior",
"search.label": "Buscar",
"search.placeholder": "Buscar…",
"search.submit": "Buscar",
"skip_to_content": "Ir ao contido",
"time_elapsed.days": [
"hai %d día",
"hai %d días"
],
"time_elapsed.hours": [
"hai %d hora",
"hai %d horas"
],
"time_elapsed.minutes": [
"hai %d minuto",
"hai %d minutos"
],
"time_elapsed.months": [
"hai %d mes",
"hai %d meses"
],
"time_elapsed.not_yet": "aínda non",
"time_elapsed.now": "xusto agora",
"time_elapsed.weeks": [
"hai %d semana",
"hai %d semanas"
],
"time_elapsed.years": [
"hai %d ano",
"hai %d anos"
],
"time_elapsed.yesterday": "onte",
"tooltip.keyboard_shortcuts": "Atallo do teclado: %s",
"tooltip.logged_user": "Sesión de %s"
}
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "श्रेणी",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "विवरण",
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Atur Kuki",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Deskripsi",
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descrizione",
"form.feed.label.disable_http2": "Disabilita HTTP/2 per evitare il fingerprinting",
"form.feed.label.disabled": "Non aggiornare questo feed",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "カテゴリ",
"form.feed.label.cookie": "Cookie の設定",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "説明",
"form.feed.label.disable_http2": "フィンガープリンティング回避のため HTTP/2 を無効化",
"form.feed.label.disabled": "このフィードを更新しない",
@@ -172,6 +172,7 @@
"form.feed.label.category": "lūi-pia̍t",
"form.feed.label.cookie": "Siat-tēng Cookies",
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Biâu-su̍t",
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.crawler": "Download originele inhoud",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Omschrijving",
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
"form.feed.label.disabled": "Deze feed niet vernieuwen",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.ignore_entry_updates": "Ignoruj aktualizacje wpisów",
"form.feed.label.description": "Opis",
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descrição",
"form.feed.label.disable_http2": "Desativar HTTP/2 para evitar fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Setare Cookie-uri",
"form.feed.label.crawler": "Aduce conținutul original",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Descriere",
"form.feed.label.disable_http2": "Dezactivează HTTP/2 pentru a preveni amprentarea",
"form.feed.label.disabled": "Nu actualiza acest flux",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Категория",
"form.feed.label.cookie": "Установить куки",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Описание",
"form.feed.label.disable_http2": "Отключить HTTP/2 для предотвращения фингерпринтинга",
"form.feed.label.disabled": "Не обновлять эту подписку",
+1
View File
@@ -175,6 +175,7 @@
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Açıklama",
"form.feed.label.disable_http2": "Parmak izini önlemek için HTTP/2'yi devre dışı bırakın",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
+1
View File
@@ -178,6 +178,7 @@
"form.feed.label.category": "Категорія",
"form.feed.label.cookie": "Встановити кукі",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.description": "Опис",
"form.feed.label.disable_http2": "Вимкнути HTTP/2 для уникнення відбитків",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "分类",
"form.feed.label.cookie": "设置 Cookie",
"form.feed.label.crawler": "获取原始内容",
"form.feed.label.ignore_entry_updates": "忽略条目更新",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "禁用 HTTP/2 以避免指纹识别",
"form.feed.label.disabled": "不刷新此订阅",
+1
View File
@@ -172,6 +172,7 @@
"form.feed.label.category": "類別",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.ignore_entry_updates": "忽略條目更新",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "停用 HTTP/2 以避免指紋追蹤",
"form.feed.label.disabled": "不要更新此 Feed",
+7
View File
@@ -55,6 +55,7 @@ type Feed struct {
PushoverEnabled bool `json:"pushover_enabled"`
NtfyEnabled bool `json:"ntfy_enabled"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyPriority int `json:"ntfy_priority"`
@@ -156,6 +157,7 @@ type FeedCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
@@ -195,6 +197,7 @@ type FeedModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
@@ -260,6 +263,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Crawler = *f.Crawler
}
if f.IgnoreEntryUpdates != nil {
feed.IgnoreEntryUpdates = *f.IgnoreEntryUpdates
}
if f.UserAgent != nil {
feed.UserAgent = *f.UserAgent
}
+2 -1
View File
@@ -21,6 +21,7 @@ func OptionalString(value string) *string {
return nil
}
//go:fix inline
func SetOptionalField[T any](value T) *T {
return &value
return new(value)
}
+1 -1
View File
@@ -21,7 +21,7 @@ type UserSession struct {
}
func (u *UserSession) String() string {
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
return fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.
+145
View File
@@ -9,6 +9,8 @@ import (
"os"
"testing"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
)
func TestCharsetReaderWithUTF8(t *testing.T) {
@@ -346,3 +348,146 @@ func TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
}
}
func TestCharsetReaderWithKOI8RLabel(t *testing.T) {
expectedUnicodeString := "Привет мир"
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
if err != nil {
t.Fatalf("Unable to build KOI8-R input: %v", err)
}
reader, err := CharsetReader("koi8-r", bytes.NewReader(input))
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
if string(data) != expectedUnicodeString {
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
}
}
func TestCharsetReaderWithUppercaseKOI8RLabel(t *testing.T) {
expectedUnicodeString := "Привет мир"
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
if err != nil {
t.Fatalf("Unable to build KOI8-R input: %v", err)
}
reader, err := CharsetReader("KOI8-R", bytes.NewReader(input))
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
if string(data) != expectedUnicodeString {
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
}
}
func TestCharsetReaderWithKOI8RFeedFixture(t *testing.T) {
file := "testdata/koi8r.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := CharsetReader("KOI8-R", f)
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
if !bytes.Contains(data, []byte("Пример RSS ленты")) {
t.Fatalf("Data does not contain expected unicode string: %s", "Пример RSS ленты")
}
if !bytes.Contains(data, []byte("Привет мир! Ёжик, чай, Москва, Санкт-Петербург.")) {
t.Fatalf("Data does not contain expected unicode string: %s", "Привет мир! Ёжик, чай, Москва, Санкт-Петербург.")
}
}
func TestNewCharsetReaderWithKOI8RContentType(t *testing.T) {
expectedUnicodeString := "Привет мир"
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
if err != nil {
t.Fatalf("Unable to build KOI8-R input: %v", err)
}
reader, err := NewCharsetReader(bytes.NewReader(input), "text/xml; charset=koi8-r")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
if string(data) != expectedUnicodeString {
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
}
}
func TestNewCharsetReaderWithKOI8RFeedFixtureAndContentType(t *testing.T) {
file := "testdata/koi8r.xml"
f, err := os.Open(file)
if err != nil {
t.Fatalf("Unable to open file: %v", err)
}
reader, err := NewCharsetReader(f, "application/rss+xml; charset=KOI8-R")
if err != nil {
t.Fatalf("Unable to create reader: %v", err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Unable to read data: %v", err)
}
if !utf8.Valid(data) {
t.Fatalf("Data is not valid UTF-8")
}
if !bytes.Contains(data, []byte("Тестовая лента в кодировке KOI8-R")) {
t.Fatalf("Data does not contain expected unicode string: %s", "Тестовая лента в кодировке KOI8-R")
}
if !bytes.Contains(data, []byte("Проверка специальных символов")) {
t.Fatalf("Data does not contain expected unicode string: %s", "Проверка специальных символов")
}
}
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="KOI8-R"?>
<rss version="2.0">
<channel>
<title>ðÒÉÍÅÒ RSS ÌÅÎÔÙ</title>
<link>http://example.com/</link>
<description>ôÅÓÔÏ×ÁÑ ÌÅÎÔÁ × ËÏÄÉÒÏ×ËÅ KOI8-R</description>
<language>ru</language>
<lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>
<item>
<title>ðÅÒ×ÁÑ ÎÏ×ÏÓÔØ</title>
<link>http://example.com/post1</link>
<guid>http://example.com/post1</guid>
<pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>
<description>
üÔÏ ÔÅÓÔÏ×ÏÅ ÏÐÉÓÁÎÉÅ Ó ÒÕÓÓËÉÍÉ ÓÉÍ×ÏÌÁÍÉ:
ðÒÉ×ÅÔ ÍÉÒ! ³ÖÉË, ÞÁÊ, íÏÓË×Á, óÁÎËÔ-ðÅÔÅÒÂÕÒÇ.
</description>
</item>
<item>
<title>÷ÔÏÒÁÑ ÎÏ×ÏÓÔØ</title>
<link>http://example.com/post2</link>
<guid>http://example.com/post2</guid>
<pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>
<description>
ðÒÏ×ÅÒËÁ ÓÐÅÃÉÁÌØÎÙÈ ÓÉÍ×ÏÌÏ×: &amp; &lt; &gt;
á ÔÁËÖÅ ÞÉÓÌÁ: 1234567890.
</description>
</item>
</channel>
</rss>
+40 -8
View File
@@ -6,20 +6,29 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"slices"
"syscall"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/urllib"
)
const (
defaultHTTPClientTimeout = 20 * time.Second
defaultAcceptHeader = "application/xml, application/atom+xml, application/rss+xml, application/rdf+xml, application/feed+json, text/html, */*;q=0.9"
defaultAcceptHeader = "application/xml,application/atom+xml,application/rss+xml,application/rdf+xml,application/feed+json,text/html,*/*;q=0.9"
)
var (
ErrHostnameResolution = errors.New("fetcher: unable to resolve request hostname")
ErrPrivateNetworkHost = errors.New("fetcher: refusing to access private network host")
)
type RequestBuilder struct {
@@ -130,16 +139,39 @@ func (r *RequestBuilder) WithoutCompression() *RequestBuilder {
}
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
dialer := &net.Dialer{
Timeout: 10 * time.Second, // Default is 30s.
KeepAlive: 15 * time.Second, // Default is 30s.
}
// Perform the private-network check inside the dialer's Control callback,
// which fires after DNS resolution but before the TCP connection is made.
// This eliminates TOCTOU / DNS-rebinding vulnerabilities: the resolved IP
// that is checked is exactly the IP that will be connected to.
allowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()
if !allowPrivateNetworks {
dialer.Control = func(network, address string, c syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if urllib.IsNonPublicIP(ip) {
return fmt.Errorf("%w %q", ErrPrivateNetworkHost, host)
}
return nil
}
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second, // Default is 30s.
KeepAlive: 15 * time.Second, // Default is 30s.
}).DialContext,
MaxIdleConns: 50, // Default is 100.
IdleConnTimeout: 10 * time.Second, // Default is 90s.
DialContext: dialer.DialContext,
MaxIdleConns: 50, // Default is 100.
IdleConnTimeout: 10 * time.Second, // Default is 90s.
}
if r.ignoreTLSErrors {
@@ -205,7 +237,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
if r.disableCompression {
req.Header.Set("Accept-Encoding", "identity")
} else {
req.Header.Set("Accept-Encoding", "br, gzip")
req.Header.Set("Accept-Encoding", "br,gzip")
}
// Set default Accept header if not already set.
@@ -7,8 +7,11 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"miniflux.app/v2/internal/config"
)
func TestNewRequestBuilder(t *testing.T) {
@@ -300,8 +303,8 @@ func TestRequestBuilder_WithoutCompression(t *testing.T) {
func TestRequestBuilder_WithCompression(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept-Encoding") != "br, gzip" {
t.Errorf("Expected Accept-Encoding to be 'br, gzip', got '%s'", r.Header.Get("Accept-Encoding"))
if r.Header.Get("Accept-Encoding") != "br,gzip" {
t.Errorf("Expected Accept-Encoding to be 'br,gzip', got '%s'", r.Header.Get("Accept-Encoding"))
}
w.WriteHeader(http.StatusOK)
}))
@@ -398,6 +401,69 @@ func TestRequestBuilder_InvalidURL(t *testing.T) {
}
}
func TestRequestBuilder_RefusePrivateNetworkByDefault(t *testing.T) {
configureFetcherAllowPrivateNetworksOption(t, "0")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
builder := NewRequestBuilder()
_, err := builder.ExecuteRequest(server.URL)
if err == nil {
t.Fatal("Expected private network request to be rejected")
}
if !strings.Contains(err.Error(), "refusing to access private network host") {
t.Fatalf("Unexpected error for private network request: %v", err)
}
}
func TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {
configureFetcherAllowPrivateNetworksOption(t, "1")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
builder := NewRequestBuilder()
resp, err := builder.ExecuteRequest(server.URL)
if err != nil {
t.Fatalf("Expected private network request to succeed when enabled: %v", err)
}
defer resp.Body.Close()
}
func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
configureFetcherAllowPrivateNetworksOption(t, "0")
// Target server on a loopback address (private).
privateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer privateServer.Close()
// Redirector that sends the client to the private server.
// Because the Control callback checks the IP at connection time, the
// redirect target is also validated (unlike a pre-flight DNS check).
redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, privateServer.URL, http.StatusFound)
}))
defer redirectServer.Close()
builder := NewRequestBuilder()
_, err := builder.ExecuteRequest(redirectServer.URL)
if err == nil {
t.Fatal("Expected redirect to private network to be rejected")
}
if !strings.Contains(err.Error(), "refusing to access private network host") {
t.Fatalf("Unexpected error for redirected private network request: %v", err)
}
}
func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
// Create a slow server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -420,3 +486,21 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
t.Errorf("Expected timeout around 1s, took %v", duration)
}
}
func configureFetcherAllowPrivateNetworksOption(t *testing.T, value string) {
t.Helper()
t.Setenv("FETCHER_ALLOW_PRIVATE_NETWORKS", value)
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
})
}
+1 -2
View File
@@ -238,8 +238,7 @@ func containsRegexPattern(pattern string, items []string) bool {
func parseDuration(duration string) (time.Duration, error) {
// Handle common duration formats like "30d", "7d", "1h", "1m", etc.
// Go's time.ParseDuration doesn't support days, so we handle them manually
if strings.HasSuffix(duration, "d") {
daysStr := strings.TrimSuffix(duration, "d")
if daysStr, ok := strings.CutSuffix(duration, "d"); ok {
days := 0
if daysStr != "" {
var err error
+23 -43
View File
@@ -27,6 +27,16 @@ var (
ErrDuplicatedFeed = errors.New("fetcher: duplicated feed")
)
func getTranslatedLocalizedError(store *storage.Storage, userID int64, originalFeed *model.Feed, localizedError *locale.LocalizedErrorWrapper) *locale.LocalizedErrorWrapper {
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
}
func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequestFromSubscriptionDiscovery) (*model.Feed, *locale.LocalizedErrorWrapper) {
slog.Debug("Begin feed creation process from subscription discovery",
slog.Int64("user_id", userID),
@@ -53,6 +63,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
subscription.Username = feedCreationRequest.Username
subscription.Password = feedCreationRequest.Password
subscription.Crawler = feedCreationRequest.Crawler
subscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates
subscription.Disabled = feedCreationRequest.Disabled
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
@@ -154,6 +165,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.Username = feedCreationRequest.Username
subscription.Password = feedCreationRequest.Password
subscription.Crawler = feedCreationRequest.Crawler
subscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates
subscription.Disabled = feedCreationRequest.Disabled
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
@@ -209,7 +221,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
weeklyEntryCount := 0
var refreshDelay time.Duration
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
var weeklyCountErr error
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
@@ -219,7 +230,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
originalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
@@ -249,7 +260,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.Warn("Feed is rate limited",
slog.String("feed_url", originalFeed.FeedURL),
slog.Int("retry_delay_in_seconds", int(retryDelay.Seconds())),
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
@@ -262,24 +272,12 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.String("feed_url", originalFeed.FeedURL),
slog.Any("error", localizedError.Error()),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
}
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
}
if ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
@@ -299,18 +297,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
updatedFeed, parseErr := parser.ParseFeed(responseHandler.EffectiveURL(), bytes.NewReader(responseBody))
if parseErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(parseErr, "error.unable_to_parse_feed", parseErr)
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
}
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
}
// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.
@@ -318,7 +308,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
feedTTLValue := updatedFeed.TTL
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()
expiresValue := responseHandler.Expires()
refreshDelay = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
refreshDelay := max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
// Set the next check at with updated arguments.
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
@@ -338,18 +328,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.Entries = updatedFeed.Entries
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
updateExistingEntries := forceRefresh || !originalFeed.Crawler
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries).
// We also skip updating existing entries if the feed has ignore_entry_updates enabled.
// Unless it is forced to refresh.
updateExistingEntries := forceRefresh || (!originalFeed.Crawler && !originalFeed.IgnoreEntryUpdates)
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
if storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
}
userIntegrations, intErr := store.Integration(userID)
@@ -390,13 +376,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
}
return nil
+2 -6
View File
@@ -25,7 +25,7 @@ func NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {
}
}
func (c *iconChecker) fetchAndStoreIcon() {
func (c *iconChecker) UpdateOrCreateFeedIcon() {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(c.feed.Cookie)
@@ -79,9 +79,5 @@ func (c *iconChecker) CreateFeedIconIfMissing() {
return
}
c.fetchAndStoreIcon()
}
func (c *iconChecker) UpdateOrCreateFeedIcon() {
c.fetchAndStoreIcon()
c.UpdateOrCreateFeedIcon()
}
+25 -65
View File
@@ -66,7 +66,11 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
// Try the website URL first, then fall back to the root URL if no icon is found.
// The website URL may include a subdirectory (e.g., https://example.org/subfolder/), and icons can be referenced relative to that path.
for _, documentURL := range []string{f.websiteURL, urllib.RootURL(f.websiteURL)} {
urls := []string{f.websiteURL}
if rootURL := urllib.RootURL(f.websiteURL); rootURL != urls[0] {
urls = []string{f.websiteURL, rootURL}
}
for _, documentURL := range urls {
if icon, err := f.fetchIconsFromHTMLDocument(documentURL); err != nil {
slog.Debug("Unable to fetch icons from HTML document",
slog.String("document_url", documentURL),
@@ -156,10 +160,6 @@ func (f *iconFinder) downloadIcon(iconURL string) (*model.Icon, error) {
slog.String("icon_url", iconURL),
)
if err := ensureRemoteIconURLAllowed(iconURL, config.Opts.IconFetchAllowPrivateNetworks()); err != nil {
return nil, err
}
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(iconURL))
defer responseHandler.Close()
@@ -257,34 +257,30 @@ func findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentTyp
return nil, fmt.Errorf("icon: unable to read document: %v", err)
}
queries := [...]string{
"link[rel='icon' i][href]",
"link[rel='shortcut icon' i][href]",
"link[rel='icon shortcut' i][href]",
"link[rel='apple-touch-icon'][href]",
}
query := `link[rel='icon' i][href],
link[rel='shortcut icon' i][href],
link[rel='icon shortcut' i][href],
link[rel='apple-touch-icon'][href]`
var iconURLs []string
for _, query := range queries {
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
for _, s := range doc.Find(query).EachIter() {
href, _ := s.Attr("href")
href = strings.TrimSpace(href)
if href == "" {
continue
}
for _, s := range doc.Find(query).EachIter() {
href, _ := s.Attr("href")
href = strings.TrimSpace(href)
if href == "" {
continue
}
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
} else {
iconURLs = append(iconURLs, absoluteIconURL)
slog.Debug("Found icon URL in HTML document",
slog.String("query", query),
slog.String("icon_href", href),
slog.String("absolute_icon_url", absoluteIconURL),
)
}
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
} else {
iconURLs = append(iconURLs, absoluteIconURL)
slog.Debug("Found icon URL in HTML document",
slog.String("query", query),
slog.String("icon_href", href),
slog.String("absolute_icon_url", absoluteIconURL),
)
}
}
@@ -334,39 +330,3 @@ func parseImageDataURL(value string) (*model.Icon, error) {
MimeType: mediaType,
}, nil
}
func ensureRemoteIconURLAllowed(iconURL string, allowPrivateNetworks bool) error {
parsedURL, err := url.Parse(iconURL)
if err != nil {
return fmt.Errorf("icon: invalid icon URL %q: %w", iconURL, err)
}
if !parsedURL.IsAbs() {
return fmt.Errorf("icon: icon URL %q must be absolute", iconURL)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("icon: unsupported icon URL scheme %q", parsedURL.Scheme)
}
hostname := parsedURL.Hostname()
if hostname == "" {
return fmt.Errorf("icon: icon URL %q has no hostname", iconURL)
}
if allowPrivateNetworks {
return nil
}
isPrivate, err := urllib.ResolvesToPrivateIP(hostname)
if err != nil {
return fmt.Errorf("icon: unable to resolve icon hostname %q: %w", hostname, err)
}
if isPrivate {
return fmt.Errorf("icon: refusing to download icon from private network host %q", hostname)
}
return nil
}
+1 -21
View File
@@ -246,12 +246,10 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
t.Fatal(err)
}
// The function processes queries in order: rel="icon", then rel="shortcut icon", etc.
// So both rel="icon" links are found first, then the rel="shortcut icon" link
expected := []string{
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==",
"https://example.org/regular-icon.ico",
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>",
"https://example.org/regular-icon.ico",
}
if len(iconURLs) != len(expected) {
@@ -400,24 +398,6 @@ func TestResizeIconWebp(t *testing.T) {
}
}
func TestEnsureRemoteIconURLAllowedRejectsPrivateNetworks(t *testing.T) {
if err := ensureRemoteIconURLAllowed("http://192.168.0.1/favicon.ico", false); err == nil {
t.Fatal("Expected private network hosts to be rejected")
}
}
func TestEnsureRemoteIconURLAllowedAllowsPublicNetworks(t *testing.T) {
if err := ensureRemoteIconURLAllowed("https://1.1.1.1/favicon.ico", false); err != nil {
t.Fatalf("Expected public network hosts to be allowed: %v", err)
}
}
func TestEnsureRemoteIconURLAllowedAllowsPrivateWhenEnabled(t *testing.T) {
if err := ensureRemoteIconURLAllowed("http://10.0.0.5/icon.png", true); err != nil {
t.Fatalf("Expected private network hosts to be allowed when explicitly enabled: %v", err)
}
}
func TestResizeInvalidImage(t *testing.T) {
icon := model.Icon{
Content: []byte("invalid data"),
+5 -1
View File
@@ -20,6 +20,8 @@ const (
FormatUnknown = "unknown"
)
const maxTokensToConsider = uint(50)
// DetectFeedFormat tries to guess the feed format from input data.
func DetectFeedFormat(r io.ReadSeeker) (string, string) {
r.Seek(0, io.SeekStart)
@@ -32,11 +34,13 @@ func DetectFeedFormat(r io.ReadSeeker) (string, string) {
r.Seek(0, io.SeekStart)
decoder := rxml.NewXMLDecoder(r)
processedTokens := uint(0)
for {
token, _ := decoder.Token()
if token == nil {
if token == nil || processedTokens == maxTokensToConsider {
break
}
processedTokens += 1
if element, ok := token.(xml.StartElement); ok {
switch element.Name.Local {
+17
View File
@@ -80,6 +80,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("filter_stage", "before_scrape"),
)
continue
}
@@ -92,6 +93,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
webpageBaseURL := ""
entry.URL = rewrite.RewriteEntryURL(feed, entry)
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
contentExtractedSuccessfully := false
if feed.Crawler && (entryIsNew || forceRefresh) {
slog.Debug("Scraping entry",
slog.Int64("user_id", user.ID),
@@ -135,11 +137,26 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
} else if extractedContent != "" {
// We replace the entry content only if the scraper doesn't return any error.
entry.Content = minifyContent(extractedContent)
contentExtractedSuccessfully = true
}
}
rewrite.ApplyContentRewriteRules(entry, feed.RewriteRules)
// Re-run filters only when extracted content replaced entry.Content.
if contentExtractedSuccessfully && filter.IsBlockedEntry(blockRules, allowRules, feed, entry) {
slog.Debug("Entry is blocked by filter rules",
slog.Int64("user_id", user.ID),
slog.String("entry_url", entry.URL),
slog.String("entry_hash", entry.Hash),
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.String("filter_stage", "after_scrape"),
)
continue
}
if webpageBaseURL == "" {
webpageBaseURL = entry.URL
}
+17 -10
View File
@@ -173,13 +173,20 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
if append {
html, _ := s.Html()
output.WriteString("<" + tag + ">" + html + "</" + tag + ">")
output.WriteByte('<')
output.WriteString(tag)
output.WriteByte('>')
output.WriteString(html)
output.WriteString("</")
output.WriteString(tag)
output.WriteByte('>')
}
})
output.WriteString("</div>")
return output.String()
}
func shouldRemoveCandidate(str string) bool {
str = strings.ToLower(str)
@@ -190,17 +197,17 @@ func shouldRemoveCandidate(str string) bool {
}
}
isFalsePositive := false
for _, maybeCandidateToRemove := range maybeCandidateToRemove {
if strings.Contains(str, maybeCandidateToRemove) {
isFalsePositive = true
break
}
}
for _, unlikelyCandidateToRemove := range unlikelyCandidateToRemove {
if strings.Contains(str, unlikelyCandidateToRemove) {
// Do we have a false positive?
for _, maybeCandidateToRemove := range maybeCandidateToRemove {
if strings.Contains(str, maybeCandidateToRemove) {
return false
}
}
// Nope, it's a true positive!
return true
return !isFalsePositive
}
}
return false
+55 -49
View File
@@ -294,12 +294,13 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
// The tag doesn't have every required attributes but we're still interested in its content
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
}
buf.WriteString("<")
buf.WriteByte('<')
buf.WriteString(n.Data)
if len(htmlAttributes) > 0 {
buf.WriteString(" " + htmlAttributes)
if htmlAttributes != "" {
buf.WriteByte(' ')
buf.WriteString(htmlAttributes)
}
buf.WriteString(">")
buf.WriteByte('>')
if isSelfContainedTag(tag) {
return nil
@@ -312,7 +313,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
buf.WriteString("</")
buf.WriteString(n.Data)
buf.WriteString(">")
buf.WriteByte('>')
default:
}
return nil
@@ -327,49 +328,16 @@ func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseU
return nil
}
func getExtraAttributes(tagName string, isYouTubeEmbed bool, sanitizerOptions *SanitizerOptions) []string {
func hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {
switch tagName {
case "a":
htmlAttributes := []string{`rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`}
if sanitizerOptions.OpenLinksInNewTab {
htmlAttributes = append(htmlAttributes, `target="_blank"`)
}
return htmlAttributes
case "video", "audio":
return []string{"controls"}
return s.href
case "iframe":
extraHTMLAttributes := []string{`sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`}
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
if isYouTubeEmbed {
extraHTMLAttributes = append(extraHTMLAttributes, `referrerpolicy="strict-origin-when-cross-origin"`)
}
return extraHTMLAttributes
case "img":
return []string{`loading="lazy"`}
default:
return nil
}
}
func hasRequiredAttributes(tagName string, attributes []string) bool {
switch tagName {
case "a":
return slices.Contains(attributes, "href")
case "iframe":
return slices.Contains(attributes, "src")
return s.src
case "source", "img":
for _, attribute := range attributes {
if attribute == "src" || attribute == "srcset" {
return true
}
}
return false
default:
return true
return s.src || s.srcset
}
return true
}
func hasValidURIScheme(absoluteURL string) bool {
@@ -508,9 +476,28 @@ func rewriteIframeURL(link string) string {
return link
}
type mandatoryAttributesStruct struct {
href bool
src bool
srcset bool
}
func trackAttributes(s *mandatoryAttributesStruct, attributeName string) {
switch attributeName {
case "href":
s.href = true
case "src":
s.src = true
case "srcset":
s.srcset = true
}
}
func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) (string, bool) {
htmlAttrs := make([]string, 0, len(attributes))
attrNames := make([]string, 0, len(attributes))
// Keep track of mandatory attributes for some tags
mandatoryAttributes := mandatoryAttributesStruct{false, false, false}
var isAnchorLink bool
var isYouTubeEmbed bool
@@ -566,7 +553,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
case tagName == "iframe":
iframeSourceDomain, trustedIframeDomain := findAllowedIframeSourceDomain(attribute.Val)
if !trustedIframeDomain {
continue
return "", false
}
value = rewriteIframeURL(attribute.Val)
@@ -581,7 +568,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
isAnchorLink = true
default:
if isBlockedResource(value) {
continue
return "", false
}
var err error
@@ -602,16 +589,35 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
}
}
attrNames = append(attrNames, attribute.Key)
trackAttributes(&mandatoryAttributes, attribute.Key)
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
}
if !hasRequiredAttributes(tagName, attrNames) {
if !hasRequiredAttributes(&mandatoryAttributes, tagName) {
return "", false
}
if !isAnchorLink {
htmlAttrs = append(htmlAttrs, getExtraAttributes(tagName, isYouTubeEmbed, sanitizerOptions)...)
switch tagName {
case "a":
htmlAttrs = append(htmlAttrs, `rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`)
if sanitizerOptions.OpenLinksInNewTab {
htmlAttrs = append(htmlAttrs, `target="_blank"`)
}
case "video", "audio":
htmlAttrs = append(htmlAttrs, "controls")
case "iframe":
htmlAttrs = append(htmlAttrs, `sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`)
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
if isYouTubeEmbed {
htmlAttrs = append(htmlAttrs, `referrerpolicy="strict-origin-when-cross-origin"`)
}
case "img":
htmlAttrs = append(htmlAttrs, `loading="lazy"`)
}
}
return strings.Join(htmlAttrs, " "), true
@@ -957,7 +957,6 @@ func TestAttrLowerCase(t *testing.T) {
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
output := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)
if tc.expected != output {
+39 -40
View File
@@ -68,9 +68,16 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
}
// It's not a feed, so we have to process its HTML.
doc, err := parseHTMLDocument(responseHandler.ContentType(), responseBody)
if err != nil {
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
}
baseURL := getBaseURL(websiteURL, doc)
// Step 2) Find the canonical URL of the website.
slog.Debug("Try to find the canonical URL of the website", slog.String("website_url", websiteURL))
websiteURL = f.findCanonicalURL(websiteURL, responseHandler.ContentType(), responseBody)
websiteURL = f.findCanonicalURL(websiteURL, baseURL, doc)
// Step 3) Check if the website URL is a YouTube channel.
slog.Debug("Try to detect feeds for a YouTube page", slog.String("website_url", websiteURL))
@@ -86,7 +93,8 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
slog.String("website_url", websiteURL),
slog.String("content_type", responseHandler.ContentType()),
)
if subscriptions, localizedError := f.findSubscriptionsFromWebPage(websiteURL, responseHandler.ContentType(), responseBody); localizedError != nil {
if subscriptions, localizedError := f.findSubscriptionsFromWebPage(baseURL, doc); localizedError != nil {
return nil, localizedError
} else if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from web page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
@@ -116,7 +124,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
return nil, nil
}
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentType string, body []byte) (Subscriptions, *locale.LocalizedErrorWrapper) {
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL string, doc *goquery.Document) (Subscriptions, *locale.LocalizedErrorWrapper) {
queries := map[string]string{
"link[type='application/rss+xml']": parser.FormatRSS,
"link[type='application/atom+xml']": parser.FormatAtom,
@@ -127,23 +135,6 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
"link[type='application/json']:not([href*='/wp-json/'])": parser.FormatJSON,
}
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
if err != nil {
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
}
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
if err != nil {
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
}
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
hrefValue = strings.TrimSpace(hrefValue)
if urllib.IsAbsoluteURL(hrefValue) {
websiteURL = hrefValue
}
}
var subscriptions Subscriptions
subscriptionURLs := make(map[string]bool)
for feedQuerySelector, feedFormat := range queries {
@@ -152,6 +143,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
subscription.Type = feedFormat
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
var err error
subscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
if err != nil {
return
@@ -326,26 +318,8 @@ func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Su
// findCanonicalURL extracts the canonical URL from the HTML <link rel="canonical"> tag.
// Returns the canonical URL if found, otherwise returns the effective URL.
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, contentType string, body []byte) string {
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
if err != nil {
return effectiveURL
}
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
if err != nil {
return effectiveURL
}
baseURL := effectiveURL
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
hrefValue = strings.TrimSpace(hrefValue)
if urllib.IsAbsoluteURL(hrefValue) {
baseURL = hrefValue
}
}
canonicalHref, exists := doc.Find("link[rel='canonical' i]").First().Attr("href")
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, baseURL string, doc *goquery.Document) string {
canonicalHref, exists := doc.Find("head link[rel='canonical' i]").First().Attr("href")
if !exists || strings.TrimSpace(canonicalHref) == "" {
return effectiveURL
}
@@ -357,3 +331,28 @@ func (f *subscriptionFinder) findCanonicalURL(effectiveURL, contentType string,
return canonicalURL
}
// getBaseURL returns the url specified in the <base> tag, and `websiteURL` otherwise.
func getBaseURL(websiteURL string, doc *goquery.Document) string {
baseURL := websiteURL
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
hrefValue = strings.TrimSpace(hrefValue)
if urllib.IsAbsoluteURL(hrefValue) {
baseURL = hrefValue
}
}
return baseURL
}
func parseHTMLDocument(contentType string, body []byte) (*goquery.Document, error) {
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
if err != nil {
return nil, err
}
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
if err != nil {
return nil, err
}
return doc, nil
}
+65 -13
View File
@@ -118,8 +118,12 @@ func TestParseWebPageWithRssFeed(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -151,8 +155,12 @@ func TestParseWebPageWithAtomFeed(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -184,8 +192,12 @@ func TestParseWebPageWithJSONFeed(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -217,8 +229,12 @@ func TestParseWebPageWithOldJSONFeedMimeType(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -251,8 +267,12 @@ func TestParseWebPageWithJSONFeedWpJsonIgnored(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -272,8 +292,12 @@ func TestParseWebPageWithRelativeFeedURL(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -305,8 +329,12 @@ func TestParseWebPageWithEmptyTitle(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -339,8 +367,12 @@ func TestParseWebPageWithMultipleFeeds(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -361,8 +393,12 @@ func TestParseWebPageWithDuplicatedFeeds(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -394,8 +430,12 @@ func TestParseWebPageWithEmptyFeedURL(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -415,8 +455,12 @@ func TestParseWebPageWithNoHref(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
if err != nil {
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
}
@@ -436,8 +480,12 @@ func TestFindCanonicalURL(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "text/html", []byte(htmlPage))
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "http://example.org", doc)
if canonicalURL != "https://example.org/canonical-page" {
t.Errorf(`Unexpected canonical URL, got %q, expected %q`, canonicalURL, "https://example.org/canonical-page")
}
@@ -452,8 +500,12 @@ func TestFindCanonicalURLNotFound(t *testing.T) {
<body>
</body>
</html>`
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
if shouldNeverHappenErr != nil {
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
}
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "text/html", []byte(htmlPage))
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "https://example.org", doc)
if canonicalURL != "https://example.org/page" {
t.Errorf(`Expected effective URL when canonical not found, got %q`, canonicalURL)
}
+10 -1
View File
@@ -131,20 +131,29 @@ func RemoveTrackingParameters(parsedFeedURL, parsedSiteURL, parsedInputUrl *url.
return "", errors.New("urlcleaner: one of the URLs is nil")
}
if parsedInputUrl.RawQuery == "" {
return parsedInputUrl.String(), nil
}
queryParams := parsedInputUrl.Query()
hasTrackers := false
feedHostname := parsedFeedURL.Hostname()
siteHostname := parsedSiteURL.Hostname()
// Remove tracking parameters
for param := range queryParams {
lowerParam := strings.ToLower(param)
if isTrackingParam(lowerParam) {
queryParams.Del(param)
hasTrackers = true
continue
}
if trackingParamsOutbound[lowerParam] {
// handle duplicate parameters like ?a=b&a=c&a=d…
for _, value := range queryParams[param] {
if value == parsedFeedURL.Hostname() || value == parsedSiteURL.Hostname() {
if value == feedHostname || value == siteHostname {
queryParams.Del(param)
hasTrackers = true
break
+56
View File
@@ -78,6 +78,62 @@ func TestXMLDocumentWithISO88591FileEncodingButUTF8Prolog(t *testing.T) {
}
}
func TestXMLDocumentWithKOI8REncoding(t *testing.T) {
fp, err := os.Open("testdata/koi8r.xml")
if err != nil {
t.Fatal(err)
}
defer fp.Close()
type item struct {
Title string `xml:"title"`
Description string `xml:"description"`
}
type channel struct {
Title string `xml:"title"`
Description string `xml:"description"`
Items []item `xml:"item"`
}
type rss struct {
XMLName xml.Name `xml:"rss"`
Channel channel `xml:"channel"`
}
var doc rss
decoder := NewXMLDecoder(fp)
err = decoder.Decode(&doc)
if err != nil {
t.Fatal(err)
}
if doc.Channel.Title != "Пример RSS ленты" {
t.Errorf("Incorrect channel title, expected: %q, got: %q", "Пример RSS ленты", doc.Channel.Title)
}
if doc.Channel.Description != "Тестовая лента в кодировке KOI8-R" {
t.Errorf("Incorrect channel description, expected: %q, got: %q", "Тестовая лента в кодировке KOI8-R", doc.Channel.Description)
}
if len(doc.Channel.Items) != 2 {
t.Fatalf("Incorrect number of items, expected: %d, got: %d", 2, len(doc.Channel.Items))
}
if doc.Channel.Items[0].Title != "Первая новость" {
t.Errorf("Incorrect first item title, expected: %q, got: %q", "Первая новость", doc.Channel.Items[0].Title)
}
if !strings.Contains(doc.Channel.Items[0].Description, "Привет мир! Ёжик, чай, Москва, Санкт-Петербург.") {
t.Errorf("First item description does not contain expected text, got: %q", doc.Channel.Items[0].Description)
}
if !strings.Contains(doc.Channel.Items[1].Description, "Проверка специальных символов") {
t.Errorf("Second item description does not contain expected text, got: %q", doc.Channel.Items[1].Description)
}
}
func TestXMLDocumentWithIllegalUnicodeCharacters(t *testing.T) {
type myxml struct {
XMLName xml.Name `xml:"rss"`
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="KOI8-R"?>
<rss version="2.0">
<channel>
<title>ðÒÉÍÅÒ RSS ÌÅÎÔÙ</title>
<link>http://example.com/</link>
<description>ôÅÓÔÏ×ÁÑ ÌÅÎÔÁ × ËÏÄÉÒÏ×ËÅ KOI8-R</description>
<language>ru</language>
<lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>
<item>
<title>ðÅÒ×ÁÑ ÎÏ×ÏÓÔØ</title>
<link>http://example.com/post1</link>
<guid>http://example.com/post1</guid>
<pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>
<description>
üÔÏ ÔÅÓÔÏ×ÏÅ ÏÐÉÓÁÎÉÅ Ó ÒÕÓÓËÉÍÉ ÓÉÍ×ÏÌÁÍÉ:
ðÒÉ×ÅÔ ÍÉÒ! ³ÖÉË, ÞÁÊ, íÏÓË×Á, óÁÎËÔ-ðÅÔÅÒÂÕÒÇ.
</description>
</item>
<item>
<title>÷ÔÏÒÁÑ ÎÏ×ÏÓÔØ</title>
<link>http://example.com/post2</link>
<guid>http://example.com/post2</guid>
<pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>
<description>
ðÒÏ×ÅÒËÁ ÓÐÅÃÉÁÌØÎÙÈ ÓÉÍ×ÏÌÏ×: &amp; &lt; &gt;
á ÔÁËÖÅ ÞÉÓÌÁ: 1234567890.
</description>
</item>
</channel>
</rss>
+8 -4
View File
@@ -263,10 +263,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
webhook_url,
disable_http2,
description,
proxy_url
proxy_url,
ignore_entry_updates
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)
RETURNING
id
`
@@ -302,6 +303,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.DisableHTTP2,
feed.Description,
feed.ProxyURL,
feed.IgnoreEntryUpdates,
).Scan(&feed.ID)
if err != nil {
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@@ -384,9 +386,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
ntfy_topic=$35,
pushover_enabled=$36,
pushover_priority=$37,
proxy_url=$38
proxy_url=$38,
ignore_entry_updates=$39
WHERE
id=$39 AND user_id=$40
id=$40 AND user_id=$41
`
_, err = s.db.Exec(query,
feed.FeedURL,
@@ -427,6 +430,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.PushoverEnabled,
feed.PushoverPriority,
feed.ProxyURL,
feed.IgnoreEntryUpdates,
feed.ID,
feed.UserID,
)

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