Compare commits

...

42 Commits

Author SHA1 Message Date
Kelly Norton 51f2e0d819 feat(atom): use id for entry link if it is an http URL 2026-06-25 20:56:36 -07:00
Hleb Kastseika 510d225b06 fix(ui): validate per-feed entry filter rules in web forms 2026-06-24 19:45:37 -07:00
dependabot[bot] ecdc3569e3 build(deps): bump the gomod group with 2 updates
Bumps the gomod group with 2 updates: [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) and [golang.org/x/image](https://github.com/golang/image).


Updates `github.com/coreos/go-oidc/v3` from 3.18.0 to 3.19.0
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.18.0...v3.19.0)

Updates `golang.org/x/image` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/image/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 16:16:27 -07:00
Frédéric Guillot f96bee0d61 feat(client): add missing fields to match API server
Add Feed response fields (description, next_check_at, no_media_player,
icon, and notification fields), the no_media_player and description
fields to the feed creation and modification requests, and a Tags
filter for entry queries.
2026-06-22 17:02:31 -07:00
Frédéric Guillot 15505142fd refactor(api): move entryIDsResponse struct to messages.go
Keep all API message types together in messages.go.
2026-06-22 15:58:43 -07:00
Frédéric Guillot 6ddddbc4c2 fix(api): remove duplicate category filter in entry list
The entry list path applied the category_id query parameter twice: once
with validation in findEntries and again without validation in
configureFilters. Drop the unvalidated second application so an invalid
category_id consistently returns a bad request.
2026-06-22 15:24:19 -07:00
Frédéric Guillot 063e3f14f0 refactor(api): parse counts parameter with QueryBoolParam
The categories handler parsed the counts query parameter by comparing
the raw string to "true". Use QueryBoolParam for consistency with the
other boolean query parameters in the package.
2026-06-22 15:20:19 -07:00
Frédéric Guillot ae1f1351bf fix(api): return server error on user lookup failure
The admin user lookup handlers reported a database error from UserByID
and UserByUsername as a bad request with a generic message. Return a
server error instead, consistent with the other user handlers.
2026-06-22 15:14:25 -07:00
Frédéric Guillot 5de36b613c fix(api): return server error when feed lookup fails on update
The update-feed handler returned a not found response for any error
from FeedByID, masking genuine database failures. Return a server error
on failure and keep the not found response for a nil feed.
2026-06-22 15:12:00 -07:00
Frédéric Guillot 2ac3073583 fix(api): check for nil user before using the result
The current-user and categories handlers used the value returned by
UserByID without checking whether it was nil. Since UserByID returns no
error when the user does not exist, the categories handler could
dereference a nil user. Return a not found response when the user is nil.
2026-06-22 15:05:52 -07:00
Frédéric Guillot ae6ac73ce4 fix(api): distinguish missing user from database error
The mark-user-as-read and integrations-status handlers treated any
error from UserByID as a 404, which masked genuine database failures
and never detected a missing user since UserByID returns no error when
the user does not exist. Return a server error on failure and a not
found response only when the user is nil.
2026-06-22 14:57:27 -07:00
Frédéric Guillot 9f4b2ef9fc fix(api): proxify enclosure URLs in entry list endpoints
The single-entry endpoint proxified enclosure URLs while the list
endpoints did not, so the same enclosure was returned with different
URLs depending on the endpoint used.
2026-06-22 14:49:15 -07:00
John Brayton bcb2cf2aa2 feat(api): Allow API client to set "starred" to true or false using the "PUT /v1/entries" endpoint 2026-06-19 17:47:32 -07:00
CULT PONY 530b0c5739 fix(urllib): fix rejection of valid proxy URLs in feeds
When attempting to set a proxy in a feed, due to a regression caused by 4cd9dd6af7
the form validation would now only accept HTTP(S) proxies, and reject SOCKS proxies, despite this being
a valid configuration before. Adding a new function to validate proxy URLs separates the concerns
and allows using a SOCKS proxy url again.
2026-06-16 20:30:19 -07:00
Cthulhux b0d25c1c68 fix(locale): de_DE updates 2026-06-16 14:07:54 -07:00
Frédéric Guillot 8699a1d119 fix(fetcher): clone request builder before disabling redirects
The subscription finder holds a shared RequestBuilder whose methods mutate
in place. Disabling redirects while probing well-known feed URLs flipped
that flag on the shared builder permanently, leaking into the finder's
other requests instead of scoping to the probe.

Add a Clone method and derive an isolated builder for the probe so the
redirect setting no longer escapes the loop.
2026-06-15 21:18:19 -07:00
Frédéric Guillot a1539659f3 fix(ui): show flash message when refreshing all feeds via keyboard
The R keyboard shortcut used a fetch request that followed the redirect
to /feeds automatically, consuming the one-time flash message before the
browser navigated there a second time, so no success alert appeared.

Submit a real form POST instead so the browser follows the redirect once
and renders the flash message, matching the menu button behavior.

Fixes: #4358
2026-06-15 20:42:01 -07:00
Frédéric Guillot 1975bc6339 fix(storage): scope enclosure lookup by user 2026-06-14 21:27:47 -07:00
Frédéric Guillot f72957e807 chore(version): bump dev version to 2.3.x-dev 2026-06-14 20:53:52 -07:00
viq ce5d84e956 feat(search): swap plainto_tsquery for websearch_to_tsquery
This switches plainto_tsquery to instead use websearch_to_tsquery,
introduced in PostgreSQL 11. With unquoted text it behaves the same, but
allows to use quoted text and OR and negation in the search terms.
2026-06-12 21:29:24 -07:00
gudvinr 49d8f1dafb refactor(response): make response builder actually build stuff 2026-06-12 21:20:53 -07:00
gudvinr 91860edab0 refactor(fetcher): make request builder actually build 2026-06-12 21:20:53 -07:00
gudvinr e49c75fc56 fix(storage): chain query builders
Correct use of builder assumes that each step makes isolated instance. Thus, not using result of build step makes builder just ignore that action.
2026-06-12 21:20:53 -07:00
gudvinr a14a180313 refactor(reader): use consistent naming for atom feed adapters 2026-06-12 21:14:16 -07:00
gudvinr 684ea3d224 feat(reader): split common sorting routine into separate function
Instead of creating unsorted slice and then sorting, just make slice sorted.
2026-06-12 21:14:16 -07:00
gudvinr 4ce8151a8f refactor(reader): make code flow consistent for buildFeed
Every build feed method does similar things in a different way. That makes it harder to read these implementations.

Remove nesting creep in loops to make code simpler.
2026-06-12 21:14:16 -07:00
John Brayton 7d8ffd2eb0 feat(api): add entry ID listing endpoint
Add GET /v1/entries/ids to return paginated entry IDs for the current user.

The endpoint supports status and starred filters, returns the total matching 
count, and exposes matching client methods.
2026-06-11 21:19:00 -07:00
dependabot[bot] 05cd8da81f build(deps): bump the gomod group with 5 updates
Bumps the gomod group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.52.0` | `0.53.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.41.0` | `0.42.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.55.0` | `0.56.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.43.0` | `0.44.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.37.0` | `0.38.0` |


Updates `golang.org/x/crypto` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/image` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/image/compare/v0.41.0...v0.42.0)

Updates `golang.org/x/net` from 0.55.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

Updates `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/text` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 20:23:42 -07:00
jvoisin f22c08832a fix(migrations): use SHA-256 instead of MD5 for the enclosures unique index
PostgreSQL 18 disables MD5 when running in FIPS mode, which made
md5(url) unusable. This broke enclosure creation with:

    store: unable to create enclosure: pq: could not compute MD5
    hash: unsupported (XX000)

Replace md5(url) with encode(sha256(url::bytea), 'hex') everywhere:

- The historical migrations that created the enclosures index are
  changed to sha256 so fresh installs no longer fail while replaying
  them on FIPS-mode PostgreSQL 18.
- A new migration rebuilds enclosures_user_entry_url_unique_idx with
  sha256 to convert existing installs.
- The ON CONFLICT clause in createEnclosure is updated to match the
  new expression index.

According to `openssl speed -bytes 256 md5 sha256`, this is a performance
improvement as well :D

Finally, the PostgreSQL minimum version was bumped from 9.5 to 11, the lowest
version to support SHA256.

Fixes #4350
2026-06-09 21:00:27 -07:00
jvoisin f4de355374 fix: correct an aria-labelledby error 2026-06-08 21:03:38 -07:00
gudvinr f963ea35a5 refactor(cli): use cleaner syntax for common exit routine 2026-06-06 20:20:52 -07:00
jvoisin 87d7891600 refactor: remove the now-useless maxDepth limit in the sanitizer
As stated in html.Parse's documentation, "Parse will reject HTML that is nested
deeper than 512 elements." So there is no need to do it ourself.
2026-06-05 20:34:00 -07:00
ghose 939698fd66 feat(locale): update gl_ES 2026-06-05 17:26:55 -07:00
jiasen 350df63d99 fix(googlereader): apply configured user agent in quickadd handler
The /reader/api/0/subscription/quickadd endpoint was creating its
request builder without calling WithUserAgent, so outbound feed
fetches used Go's default user agent (Go-http-client/2.x) instead
of the operator-configured HTTP_CLIENT_USER_AGENT.

Sites like Reddit that block Go's default user agent would return
403, causing quickadd to fail even though adding the same feed via
the Miniflux web UI succeeded (the UI's subscription handler correctly
sets the configured user agent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:14:45 -07:00
Frédéric Guillot 0cfd0798b9 fix(locale): drop unused arguments when formatting translations
Translation forms that intentionally omit the count, such as the Arabic
dual, no longer render a trailing %!(EXTRA ...) marker. The printer now
formats with the supplied arguments only when the string has a real
directive, and skips them otherwise while still unescaping percents.

This also fixes the same issue in the Polish and Romanian one-forms, and
makes the missing-translation fallback return the bare key.
2026-06-04 20:56:24 -07:00
dependabot[bot] f4b393748d build(deps): bump the github-actions group with 9 updates
Bumps the github-actions group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `6.0.3` |
| [github/codeql-action](https://github.com/github/codeql-action) | `4.35.3` | `4.36.1` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.0.0` | `4.1.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.0.0` | `6.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `9.2.0` | `9.2.1` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

Updates `github/codeql-action` from 4.35.3 to 4.36.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e46ed2cbd01164d986452f91f178727624ae40d7...87557b9c84dde89fdd9b10e88954ac2f4248e463)

Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/metadata-action` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/030e881283bb7a6894de51c315a6bfe6a94e05cf...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

Updates `golangci/golangci-lint-action` from 9.2.0 to 9.2.1
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...82606bf257cbaff209d206a39f5134f0cfbfd2ee)

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:52:05 -07:00
jvoisin 102989656b perf(template): precompute static icon URLs at parse time
iconPath() is called a bunch of times on virtually every pages. Each call did a
fmt.Sprintf to build a string of the form
"{basePath}/icon/{checksum}/{filename}". Since both the base path and
BinaryBundles are fixed at startup, all results are determinate.

This commit precomputes the full URL for every embedded bundle once in
funcMap.Map() into a map[string]string and turns iconPath into a single map
lookup. The "_/" fallback for unknown filenames was unused, as every caller
passes a compile-time literal present in bin/, so it was removed.

Microbenchmarks are showing stupidly high gains of course, but anything macro
is non-trivial, and I gave up on it. Knowing that it removes at least one heap
allocation and a couple of reflection calls on every iconPath call is enough to
bring me joy.
2026-06-02 20:58:34 -07:00
jvoisin 533ff80744 perf(ui): add max-age to Cache-Control
While inspecting the network requests done by miniflux' web interface, I
noticed that immutable assets with the `immutable` header were still fetched.
So I reread
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control:

> immutable tells a cache that the response is immutable while it's fresh and
avoids those kinds of unnecessary conditional requests to the server.

So it needs to have max-age as well, otherwise the browser will consider the
resource as not fresh, and will perform a network request (and get a 304).
2026-06-02 20:52:20 -07:00
jvoisin 83ea3d1912 security(user): don't leak usernames existence via timing
When a non-existent username was submitted, CheckPassword returned
immediately without performing a bcrypt comparison, making it possible
to distinguish valid from invalid usernames by measuring response time.

Perform a dummy bcrypt comparison against a fixed cost-10 hash when
the user is not found so the response time is indistinguishable from
a real password check.
2026-06-02 20:44:42 -07:00
jvoisin 651fbd1560 perf(filter): cache compiled regexes across entries
Filter rules are evaluated once per entry on every feed refresh. The
previous code called regexp.Compile / regexp.MatchString per call,
recompiling the same patterns N times per refresh: once per entry per
filter rule, plus once per entry for feed.BlocklistRules and
feed.KeeplistRules.

This commit routes all regex compilations through a small cachedRegex() helper
that memoizes results in a process-wide map (RWMutex protected, since we need
len() and atomic reset that sync.Map doesn't expose). A nil cached value means
the pattern previously failed to compile.

To prevent unbounded memory growth from an authenticated user churning
distinct patterns, the cache is completely reset once it reaches
maxCachedRegexes entries.

Benchmarked on a 50-entry refresh with 6 distinct regex rules:

  before:  ~408 µs/op   11,580 B/op   131 allocs/op
  after:   ~271 µs/op       ~0 B/op     0 allocs/op

Making it roughly 33% faster with zero allocations per feed-refresh batch,
scaling linearly with entry count, yay.
2026-06-01 19:37:50 -07:00
jvoisin 5e766332be perf(storage): replace correlated subqueries in CategoriesWithFeedCount
The old query ran two correlated subqueries per category row: one to
count feeds and one to count unread entries. For N categories this meant
2xN subquery executions.

This commit replaces them with two pre-aggregated subqueries joined once to
categories.

The change was validated against my live database with 5 categories, ~500 feeds
and 12k unreads:

|                     | Old (correlated)    | New (pre-aggregated)   |
|---------------------|---------------------|------------------------|
| Execution time      | 3.216 ms            | 3.123 ms               |
| Planning time       | 0.732 ms            | 0.778 ms               |
| Buffer hits         | 3,141               | 540                    |
| Index searches      | 1,237               | 1                      |
| Seq scans on feeds  | 10 (2 per category) | 2 (once each subquery) |
| Subquery executions | 10 (2x5 categories) | 2 (fixed)              |
2026-06-01 17:25:33 -07:00
Dennis Eriksen 2717336d2c docs: Specify unit of time for POLLING_FREQUENCY and list-separation for TRUSTED_REVERSE_PROXY_NETWORKS 2026-05-30 19:18:56 -07:00
98 changed files with 2035 additions and 815 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Golang
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Mirror to Codeberg
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
if: matrix.language == 'go'
@@ -46,14 +46,14 @@ jobs:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{ matrix.language }}"
+9 -9
View File
@@ -19,13 +19,13 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -40,13 +40,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -66,13 +66,13 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
+10 -10
View File
@@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -38,7 +38,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -52,21 +52,21 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -74,14 +74,14 @@ jobs:
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
@@ -90,7 +90,7 @@ jobs:
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,7 +38,7 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Python
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
@@ -31,7 +31,7 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
@@ -48,7 +48,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-pr-stale: 60
days-before-pr-close: 14
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:9.5
image: postgres:11
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -44,7 +44,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
+71
View File
@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}
// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryIDsContext(ctx, filter)
}
// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
if err != nil {
return nil, err
}
defer body.Close()
var result EntryIDsResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
if filter == nil {
return path
}
params := url.Values{}
if filter.Limit > 0 {
params.Set("limit", strconv.Itoa(filter.Limit))
}
if filter.Offset > 0 {
params.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.Starred != nil {
params.Set("starred", strconv.FormatBool(*filter.Starred))
}
if filter.Status != "" {
params.Set("status", filter.Status)
}
if len(params) == 0 {
return path
}
return path + "?" + params.Encode()
}
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
@@ -981,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
return err
}
// UpdateEntriesStarred updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
}
// UpdateEntriesStarredContext updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
@@ -1231,6 +1298,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
values.Add("status", status)
}
for _, tag := range filter.Tags {
values.Add("tags", tag)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
+128
View File
@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
}
}
func TestUpdateEntriesStarred(t *testing.T) {
starred := true
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
expectFromJSON(t, req.Body, &struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}{
EntryIDs: []int64{1, 2},
Starred: &starred,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
expected := &Entry{
ID: 1,
@@ -1286,3 +1307,110 @@ func TestUpdateEnclosure(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
}
func boolPtr(b bool) *bool { return &b }
func TestEntryIDsNoFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 2,
EntryIDs: []int64{1, 2},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithPaginationFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 5,
EntryIDs: []int64{3},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStarredFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 1,
EntryIDs: []int64{42},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStatusFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 10,
EntryIDs: []int64{7, 8},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithCombinedFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 3,
EntryIDs: []int64{5},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
+29
View File
@@ -146,12 +146,15 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -172,6 +175,14 @@ type Feed struct {
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverEnabled bool `json:"pushover_enabled"`
PushoverPriority int `json:"pushover_priority"`
Icon *FeedIcon `json:"icon"`
}
// FeedCreationRequest represents the request to create a feed.
@@ -185,6 +196,7 @@ type FeedCreationRequest struct {
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"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -205,6 +217,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
@@ -220,6 +233,7 @@ type FeedModificationRequest struct {
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
NoMediaPlayer *bool `json:"no_media_player"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
@@ -318,6 +332,7 @@ type Filter struct {
CategoryID int64
FeedID int64
Statuses []string
Tags []string
GloballyVisible bool
}
@@ -327,6 +342,20 @@ type EntryResultSet struct {
Entries Entries `json:"entries"`
}
// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
type EntryIDsFilter struct {
Limit int
Offset int
Starred *bool
Status string
}
// EntryIDsResultSet represents the response when fetching entry ID lists.
type EntryIDsResultSet struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
// VersionResponse represents the version and the build information of the Miniflux instance.
type VersionResponse struct {
Version string `json:"version"`
+7 -7
View File
@@ -8,17 +8,17 @@ go 1.26.0
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
github.com/coreos/go-oidc/v3 v3.18.0
github.com/coreos/go-oidc/v3 v3.19.0
github.com/go-webauthn/webauthn v0.17.4
github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.43.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
)
require (
@@ -45,6 +45,6 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
+14 -14
View File
@@ -8,8 +8,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -88,10 +88,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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
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=
@@ -106,8 +106,8 @@ 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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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=
@@ -128,8 +128,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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.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=
@@ -139,8 +139,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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
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=
@@ -150,8 +150,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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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=
+2 -1
View File
@@ -54,8 +54,9 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusAndStarredHandler)
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
+297
View File
@@ -2631,6 +2631,303 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
}
}
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entryID := result.Entries[0].ID
// Star the entry without changing its status.
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(entryID)
if err != nil {
t.Fatal(err)
}
if !entry.Starred {
t.Fatalf(`Expected entry to be starred`)
}
if entry.Status != miniflux.EntryStatusUnread {
t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
}
// Unstar the entry.
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
t.Fatal(err)
}
entry, err = regularUserClient.Entry(entryID)
if err != nil {
t.Fatal(err)
}
if entry.Starred {
t.Fatalf(`Expected entry to no longer be starred`)
}
}
func TestGetEntryIDsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
boolPtr := func(b bool) *bool { return &b }
// A new user should have no entries at all.
result, err := regularUserClient.EntryIDs(nil)
if err != nil {
t.Fatal(err)
}
if result.EntryIDs == nil {
t.Fatal(`Entry IDs should not be nil`)
}
if len(result.EntryIDs) != 0 {
t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
}
if result.Total != 0 {
t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
}
// Subscribe to a feed so there are entries.
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
allEntries, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
if len(allEntries.Entries) == 0 {
t.Fatal(`Expected feed to have entries`)
}
// Without filters, all entries should be returned.
result, err = regularUserClient.EntryIDs(nil)
if err != nil {
t.Fatal(err)
}
if len(result.EntryIDs) != allEntries.Total {
t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
}
if result.Total != allEntries.Total {
t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
}
// Filter by status=unread: all entries should be unread initially.
unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
if err != nil {
t.Fatal(err)
}
if len(unreadResult.EntryIDs) != allEntries.Total {
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
}
// Mark one entry as read and verify status filter results update.
firstEntryID := allEntries.Entries[0].ID
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
if err != nil {
t.Fatal(err)
}
if len(unreadResult.EntryIDs) != allEntries.Total-1 {
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
}
if unreadResult.Total != allEntries.Total-1 {
t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
}
for _, id := range unreadResult.EntryIDs {
if id == firstEntryID {
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
}
}
readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
if err != nil {
t.Fatal(err)
}
if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
}
// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
if allEntries.Total >= 2 {
pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(pagedResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
}
if pagedResult.Total != allEntries.Total-1 {
t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
}
// offset=1 should skip the first entry.
offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
if err != nil {
t.Fatal(err)
}
if len(offsetResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
}
if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
}
}
// Filter by starred=true: initially no starred entries.
starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
}
// Star the first entry and verify it appears in starred results.
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
}
if starredResult.Total != 1 {
t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
}
if starredResult.EntryIDs[0] != firstEntryID {
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
}
// The read starred entry should appear when filtering by starred=true (read status does not affect it).
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 1 {
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
}
// starred=false should exclude the starred entry.
notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
if err != nil {
t.Fatal(err)
}
for _, id := range notStarredResult.EntryIDs {
if id == firstEntryID {
t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
}
}
// Pagination with offset past the single starred result should return 0 entries but total 1.
pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
if err != nil {
t.Fatal(err)
}
if len(pagedStarred.EntryIDs) != 0 {
t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
}
if pagedStarred.Total != 1 {
t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
}
// Unstarring the entry should remove it from starred=true results.
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
}
if starredResult.Total != 0 {
t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
}
// Invalid starred value should return 400.
_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
if err == nil {
t.Fatal(`Expected error for invalid status parameter, got nil`)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+85
View File
@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
}
}
func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestParseEntryIDsParamsDefaults(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected default limit 10000, got %d`, limit)
}
if offset != 0 {
t.Fatalf(`Expected default offset 0, got %d`, offset)
}
}
func TestParseEntryIDsParamsCustomValues(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 500 {
t.Fatalf(`Expected limit 500, got %d`, limit)
}
if offset != 100 {
t.Fatalf(`Expected offset 100, got %d`, offset)
}
}
func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
}
}
func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
}
}
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
scenarios := []struct {
name string
+7 -2
View File
@@ -112,14 +112,19 @@ func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Reque
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
var categories model.Categories
var err error
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
if request.QueryBoolParam(r, "counts", false) {
user, userErr := h.store.UserByID(request.UserID(r))
if userErr != nil {
response.JSONServerError(w, r, userErr)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
} else {
categories, err = h.store.Categories(request.UserID(r))
+2 -14
View File
@@ -22,7 +22,7 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -33,12 +33,6 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, enclosure)
@@ -62,7 +56,7 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -73,12 +67,6 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
response.JSONServerError(w, r, err)
+83 -22
View File
@@ -175,11 +175,11 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
builder = builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
builder = configureFilters(builder, r)
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
@@ -189,26 +189,36 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
entries[i].Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
}
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
if entriesStatusUpdateRequest.Status != "" {
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
}
if entriesStatusUpdateRequest.Starred != nil {
if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
response.JSONServerError(w, r, err)
return
}
}
response.NoContent(w, r)
@@ -497,57 +507,108 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
if request.HasQueryParam(r, "starred") {
starredValue := request.QueryStringParam(r, "starred", "")
if starredValue != "true" && starredValue != "false" {
response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
return
}
}
if request.HasQueryParam(r, "status") {
statusValue := request.QueryStringParam(r, "status", "")
if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
return
}
}
limit, offset := parseEntryIDsParams(r)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithSorting("id", "DESC").
WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
WithOffset(offset)
if request.HasQueryParam(r, "starred") {
builder.WithStarred(request.QueryBoolParam(r, "starred", false))
}
if request.HasQueryParam(r, "status") {
builder.WithStatuses(request.QueryStringParam(r, "status", ""))
}
entryIDs, total, err := builder.GetEntryIDsWithCount()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entryIDs == nil {
entryIDs = []int64{}
}
response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
}
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
response.JSONAccepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) *storage.EntryQueryBuilder {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
builder = builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
builder = builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
builder.WithCategoryID(categoryID)
builder = builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
builder = builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
builder = builder.WithSearchQuery(searchQuery)
}
return builder
}
func parseEntryIDsParams(r *http.Request) (limit, offset int) {
limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
if limit <= 0 || limit > model.MaxEntryIDsLimit {
limit = model.MaxEntryIDsLimit
}
offset = request.QueryIntParam(r, "offset", 0)
return limit, offset
}
+1 -1
View File
@@ -115,7 +115,7 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
response.JSONNotFound(w, r)
response.JSONServerError(w, r, err)
return
}
+5
View File
@@ -26,6 +26,11 @@ type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryIDsResponse struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
+11 -11
View File
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Re
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy).
WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(subscriptionDiscoveryRequest.Cookie).
WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password).
IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates).
DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
+21 -4
View File
@@ -22,6 +22,11 @@ func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
@@ -113,7 +118,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
return
}
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -128,7 +139,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -181,7 +198,7 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
@@ -203,7 +220,7 @@ func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request)
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
+4 -4
View File
@@ -25,24 +25,24 @@ func askCredentials() (string, string) {
reader := bufio.NewReader(os.Stdin)
username, err := reader.ReadString('\n')
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
printfAndExit("unable to read username: %w", err)
}
fmt.Print("Enter Password: ")
state, err := term.GetState(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
printfAndExit("unable to get terminal state: %w", err)
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
printfAndExit("unable to restore terminal state: %w", restoreErr)
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
printfAndExit("unable to read password: %w", err)
}
fmt.Print("\n")
+11 -6
View File
@@ -124,7 +124,7 @@ func Parse() {
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
printfAndExit("unable to open log file: %v", err)
}
defer logFileHandler.(*os.File).Close()
}
@@ -143,15 +143,15 @@ func Parse() {
}
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
printfAndExit("unable to generate binary files bundle: %v", err)
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
printfAndExit("unable to generate stylesheets bundle: %v", err)
}
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
printfAndExit("unable to generate javascript bundle: %v", err)
}
db, err := database.NewConnectionPool(
@@ -161,7 +161,7 @@ func Parse() {
config.Opts.DatabaseConnectionLifetime(),
)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
printfAndExit("unable to connect to database: %v", err)
}
defer db.Close()
@@ -231,7 +231,7 @@ func Parse() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
printfAndExit("unable to initialize proxy rotator: %v", err)
}
}
@@ -252,3 +252,8 @@ func printErrorAndExit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func printfAndExit(format string, args ...any) {
err := fmt.Errorf(format, args...)
printErrorAndExit(err)
}
+3 -3
View File
@@ -13,17 +13,17 @@ import (
func exportUserFeeds(store *storage.Storage, username string) {
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
printfAndExit("unable to find user: %w", err)
}
if user == nil {
printErrorAndExit(fmt.Errorf("user %q not found", username))
printfAndExit("user %q not found", username)
}
opmlHandler := opml.NewHandler(store)
opmlExport, err := opmlHandler.Export(user.ID)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
printfAndExit("unable to export feeds: %w", err)
}
fmt.Println(opmlExport)
+2 -3
View File
@@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"log/slog"
"net/http"
"time"
@@ -22,12 +21,12 @@ func doHealthCheck(healthCheckEndpoint string) {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
printfAndExit(`health check failure: %v`, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
printfAndExit(`health check failed with status code %d`, resp.StatusCode)
}
slog.Debug(`Health check is passing`)
+22 -2
View File
@@ -353,7 +353,11 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
// This migration originally used md5(url), but it was changed to
// sha256 because PostgreSQL 18 disables MD5 in FIPS mode, which made
// fresh installs fail while replaying this migration. Existing
// installs that already ran it are migrated later on.
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`
_, err = tx.Exec(sql)
return err
},
@@ -724,7 +728,12 @@ var migrations = [...]func(tx *sql.Tx) error{
}
// Create unique index
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
//
// This originally used md5(url), but it was changed to sha256 because
// PostgreSQL 18 disables MD5 in FIPS mode, which made fresh installs
// fail while replaying this migration. Existing installs that already
// ran it are migrated later on.
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`)
if err != nil {
return err
}
@@ -1525,4 +1534,15 @@ var migrations = [...]func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
// PostgreSQL 18 disables MD5 when running in FIPS mode, which makes
// the unique index on enclosures relying on md5(url) unusable.
// Replace it with a SHA-256 based expression index.
_, err = tx.Exec(`
DROP INDEX IF EXISTS enclosures_user_entry_url_unique_idx;
CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx
ON enclosures (user_id, entry_id, encode(sha256(url::bytea), 'hex'));
`)
return err
},
}
+6 -6
View File
@@ -249,8 +249,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
builder = builder.AfterEntryID(sinceID)
builder = builder.WithSorting("id", "ASC")
}
case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0)
@@ -258,14 +258,14 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC")
builder = builder.WithSorting("id", "DESC")
} else if maxID > 0 {
slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC")
builder = builder.BeforeEntryID(maxID)
builder = builder.WithSorting("id", "DESC")
}
case request.HasQueryParam(r, "with_ids"):
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
@@ -278,7 +278,7 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
itemIDs = append(itemIDs, itemID)
}
builder.WithEntryIDs(itemIDs...)
builder = builder.WithEntryIDs(itemIDs...)
}
default:
slog.Debug("[Fever] Fetching oldest items",
+14 -13
View File
@@ -342,9 +342,10 @@ func (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request)
return
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithUserAgent("", config.Opts.HTTPClientUserAgent())
var rssBridgeURL string
var rssBridgeToken string
@@ -1018,7 +1019,7 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
builder.WithStatuses(model.EntryStatusUnread)
builder = builder.WithStatuses(model.EntryStatusUnread)
default:
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
@@ -1030,11 +1031,11 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1053,11 +1054,11 @@ func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *ht
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1077,11 +1078,11 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1129,16 +1130,16 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
builder = builder.WithoutStatus(model.EntryStatusRead)
}
}
+6 -6
View File
@@ -120,10 +120,10 @@ type contentItemOrigin struct {
}
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
builder := response.NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithBodyAsString("Unauthorized")
builder.Write()
response.NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("X-Reader-Google-Bad-Token", "true").
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithBodyAsString("Unauthorized").
Write()
}
+4 -1
View File
@@ -6,6 +6,7 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"maps"
@@ -86,7 +87,9 @@ func (b *Builder) WithoutCompression() *Builder {
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
etag = normalizeETag(etag)
b.headers.Set("ETag", etag)
b.headers.Set("Cache-Control", "public, immutable")
// max-age is required for the "immutable" directive to take effect: without
// it, browsers still revalidate content-hashed assets on every reload.
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
+2 -2
View File
@@ -240,7 +240,7 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public, immutable"
expectedHeader := "public, max-age=60, immutable"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
@@ -297,7 +297,7 @@ func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
}
if resp.Header.Get("Cache-Control") != "public, immutable" {
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
}
+40 -38
View File
@@ -15,15 +15,17 @@ import (
// HTML creates a new HTML response with a 200 status code.
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder := NewBuilder(w, r).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
switch v := any(body).(type) {
case []byte:
builder.WithBodyAsBytes(v)
builder = builder.WithBodyAsBytes(v)
case string:
builder.WithBodyAsString(v)
builder = builder.WithBodyAsString(v)
}
builder.Write()
}
@@ -42,13 +44,13 @@ func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLBadRequest sends a bad request error to the client.
@@ -66,13 +68,13 @@ func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLForbidden sends a forbidden error to the client.
@@ -89,12 +91,12 @@ func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Access Forbidden")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Access Forbidden").
Write()
}
// HTMLNotFound sends a page not found error to the client.
@@ -111,12 +113,12 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Page Not Found")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Page Not Found").
Write()
}
// HTMLRedirect redirects the user to a relative path or an absolute http(s) URL.
@@ -142,11 +144,11 @@ func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, co
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithHeader("Content-Range", contentRange)
builder.WithBodyAsString("Range Not Satisfiable")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusRequestedRangeNotSatisfiable).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithHeader("Content-Range", contentRange).
WithBodyAsString("Range Not Satisfiable").
Write()
}
+38 -38
View File
@@ -22,10 +22,10 @@ func JSON(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONCreated sends a created response to the client.
@@ -36,19 +36,19 @@ func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusCreated).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONAccepted sends an accepted response to the client.
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusAccepted).
WithHeader("Content-Type", jsonContentTypeHeader).
Write()
}
// JSONServerError sends an internal error to the client.
@@ -66,11 +66,11 @@ func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONBadRequest sends a bad request error to the client.
@@ -88,11 +88,11 @@ func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONUnauthorized sends a not authorized error to the client.
@@ -109,11 +109,11 @@ func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access unauthorized"))).
Write()
}
// JSONForbidden sends a forbidden error to the client.
@@ -130,11 +130,11 @@ func JSONForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access forbidden"))).
Write()
}
// JSONNotFound sends a page not found error to the client.
@@ -151,11 +151,11 @@ func JSONNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
Write()
}
func generateJSONError(err error) []byte {
+3 -3
View File
@@ -17,7 +17,7 @@ const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-actio
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNoContent)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNoContent).
Write()
}
+4 -4
View File
@@ -7,8 +7,8 @@ import "net/http"
// Text writes a standard text response with a status 200 OK.
func Text(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", `text/plain; charset=utf-8`).
WithBodyAsString(body).
Write()
}
+9 -9
View File
@@ -7,17 +7,17 @@ import "net/http"
// XML writes a standard XML response with a status 200 OK.
func XML(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithBodyAsString(body).
Write()
}
// XMLAttachment forces the XML document to be downloaded by the web browser.
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithAttachment(filename).
WithBodyAsString(body).
Write()
}
+4 -4
View File
@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
t.Errorf("Expected French translation %q, got %q", expected, result)
}
// Test with missing language (should use key as fallback with args applied)
// Test with missing language (should fall back to the untranslated key)
result = wrapper.Translate("invalid_lang")
expected = "error.test_key%!(EXTRA string=test message, int=404)"
expected = "error.test_key"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
localizedErr := NewLocalizedError("error.missing", "arg1")
result := localizedErr.String()
expected := "error.missing%!(EXTRA string=arg1)"
expected := "error.missing"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
// Test with missing language
result = localizedErr.Translate("invalid_lang")
expected = "error.permission%!(EXTRA string=admin panel)"
expected = "error.permission"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
+32 -2
View File
@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...any) string {
return fmt.Sprintf(p.Print(key), args...)
return formatTranslation(p.Print(key), args...)
}
// Plural returns the translation of the given key by using the language plural form.
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
return formatTranslation(choices[index], args...)
}
}
return key
}
// formatTranslation skips extra arguments when the translation references no argument,
// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
func formatTranslation(format string, args ...any) string {
if !hasFormattingDirective(format) {
return fmt.Sprintf(format, []any{}...)
}
return fmt.Sprintf(format, args...)
}
// hasFormattingDirective reports whether the format should be handled with the
// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
// any other percent sequence, including a dangling "%".
func hasFormattingDirective(format string) bool {
for index := 0; index < len(format); index++ {
if format[index] != '%' {
continue
}
if index+1 >= len(format) {
return true
}
if format[index+1] == '%' {
index++ // skip the escaped percent
continue
}
return true
}
return false
}
+56
View File
@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
}
}
}
func TestPluralFormWithoutPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"ar_SA": translationDict{
plurals: map[string][]string{
// The Arabic dual omits the count by design.
"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
},
},
}
printer := NewPrinter("ar_SA")
if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
}
}
func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"media.completion": "Mark as read at 90%% completion",
},
},
}
got := NewPrinter("en_US").Printf("media.completion")
expected := "Mark as read at 90% completion"
if got != expected {
t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
}
}
func TestHasFormattingDirective(t *testing.T) {
tests := map[string]bool{
"دقيقتين": false,
"%d دقيقة": true,
"90%% done": false, // escaped percent consumes no argument
"%d of %s": true,
"": false,
"%": true,
}
for format, expected := range tests {
if got := hasFormattingDirective(format); got != expected {
t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
}
}
}
+2 -2
View File
@@ -176,7 +176,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.ignore_entry_updates": "Updates ignorieren",
"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",
@@ -186,7 +186,7 @@
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keep_filter_entry_rules": "Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
+15 -15
View File
@@ -170,7 +170,7 @@
"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.apprise_service_urls": "Lista de URLs separadas por comas 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",
@@ -216,7 +216,7 @@
"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_services_url": "Lista de URLs separadas por comas 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",
@@ -305,9 +305,9 @@
"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_api_key": "Clave da API de Readeck",
"form.integration.readeck_endpoint": "URL de Readeck",
"form.integration.readeck_labels": "Etiquetas Readeck",
"form.integration.readeck_labels": "Etiquetas para 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",
@@ -329,27 +329,27 @@
"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_bot_token": "Token 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_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.wallabag_tags": "Etiquetas para 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.application_settings": "Axustes da aplicación",
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
"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.help.external_font_hosts": "Lista de servidores de tipos de letra externos permitidos separados por espazos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ler artigos abrindo 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)",
@@ -367,11 +367,11 @@
"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.mark_read_on_media_completion": "Só marcar como lido cando acada o 90%% da reprodución",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente como lidas as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Para son/vídeo, marcar como lido ao chegar ao 90%% da reprodución",
"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.open_external_links_in_new_tab": "Abrir ligazóns externas en nova pestana (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",
@@ -428,8 +428,8 @@
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.authors_label": "Autorías:",
"page.about.authors_value": "Frédéric Guillot e colaboradores",
"page.about.authors_label": "Autoría:",
"page.about.authors_value": "Frédéric Guillot e colaboradoras",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
+5
View File
@@ -19,6 +19,10 @@ const (
// and for the user "entries_per_page" preference.
const MaxEntryLimit = 1000
// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
// for the entry ID list endpoints.
const MaxEntryIDsLimit = 10000
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
@@ -76,6 +80,7 @@ type Entries []*Entry
type EntriesStatusUpdateRequest struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
Starred *bool `json:"starred"`
}
// EntryUpdateRequest represents a request to update an entry.
+17 -9
View File
@@ -5,6 +5,7 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
@@ -19,7 +20,10 @@ type atom03Adapter struct {
}
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
feed := &model.Feed{
FeedURL: baseURL,
SiteURL: baseURL,
}
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
@@ -27,8 +31,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
@@ -37,8 +39,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
@@ -69,6 +69,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
@@ -81,17 +82,24 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
if value = strings.TrimSpace(value); value == "" {
continue
}
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
continue
}
entry.Date = parsedDate
break
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
+110 -83
View File
@@ -5,8 +5,6 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -22,12 +20,11 @@ type atom10Adapter struct {
atomFeed *atom10Feed
}
func NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {
return &atom10Adapter{atomFeed}
}
func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
func (a *atom10Adapter) buildFeed(baseURL string) *model.Feed {
feed := &model.Feed{
FeedURL: baseURL,
SiteURL: baseURL,
}
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
@@ -35,8 +32,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
@@ -45,8 +40,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
@@ -59,15 +52,17 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed.Description = a.atomFeed.Subtitle.body()
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
feed.IconURL = absoluteIconURL
for _, value := range []string{a.atomFeed.Icon, a.atomFeed.Logo} {
if value = strings.TrimSpace(value); value == "" {
continue
}
} else if a.atomFeed.Logo != "" {
if absoluteLogoURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
feed.IconURL = absoluteLogoURL
if iconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, value); err == nil {
feed.IconURL = iconURL
break
}
}
feed.Entries = a.populateEntries(feed.SiteURL)
return feed
}
@@ -86,6 +81,16 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
}
}
// If the entry has no links, attempt to use its ID as a URL
// and if that fails, use the site URL.
if entry.URL == "" {
if urllib.IsAbsoluteURL(atomEntry.ID) {
entry.URL = atomEntry.ID
} else {
entry.URL = siteURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.body()
if entry.Content == "" {
@@ -109,39 +114,39 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if len(authors) == 0 {
authors = a.atomFeed.Authors.personNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
entry.Author = strings.Join(authors, ", ")
// Populate the entry date.
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
if value != "" {
if parsedDate, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = parsedDate
break
}
if value = strings.TrimSpace(value); value == "" {
continue
}
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
continue
}
entry.Date = parsedDate
break
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate categories.
categories := atomEntry.Categories.CategoryNames()
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
entry.Tags = atomEntry.Categories.CategoryNames()
if len(entry.Tags) == 0 {
entry.Tags = a.atomFeed.Categories.CategoryNames()
}
// Sort and deduplicate categories.
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
// Populate the commentsURL if defined.
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
@@ -167,22 +172,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
if _, found := uniqueEnclosuresMap[mediaURL]; found {
continue
}
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
@@ -193,17 +204,21 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
continue
}
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; found {
continue
}
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
for _, mediaContent := range atomEntry.AllMediaContents() {
@@ -211,22 +226,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
continue
}
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
@@ -234,22 +255,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
continue
}
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
entries = append(entries, entry)
+28
View File
@@ -1837,3 +1837,31 @@ func TestParseFeedWithIconURL(t *testing.T) {
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
}
}
func TestParseEntryWithIDAsURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<link href="http://example.org/atom" rel="self"/>
<entry>
<id>http://www.example.org/entries/1</id>
</entry>
<entry>
<id>mailto:john.doe@example.org</id>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[1].URL != "http://example.org/" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[1].URL)
}
}
+43 -28
View File
@@ -4,6 +4,8 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"cmp"
"slices"
"strings"
)
@@ -32,19 +34,9 @@ func (a *AtomPerson) PersonName() string {
type atomPersons []*AtomPerson
// personNames returns sorted and deduplicated author names.
func (a atomPersons) personNames() []string {
names := make([]string, 0, len(a))
authorNamesMap := make(map[string]bool, len(a))
for _, person := range a {
personName := person.PersonName()
if _, ok := authorNamesMap[personName]; !ok {
names = append(names, personName)
authorNamesMap[personName] = true
}
}
return names
return makeSorted((*AtomPerson).PersonName, a)
}
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
@@ -134,22 +126,45 @@ type atomCategory struct {
Label string `xml:"label,attr"`
}
type atomCategories []atomCategory
func (ac atomCategories) CategoryNames() []string {
categories := make([]string, 0, len(ac))
for _, category := range ac {
label := strings.TrimSpace(category.Label)
if label != "" {
categories = append(categories, label)
} else {
term := strings.TrimSpace(category.Term)
if term != "" {
categories = append(categories, term)
}
}
func (ac atomCategory) name() string {
name := strings.TrimSpace(ac.Label)
if name != "" {
return name
}
return categories
name = strings.TrimSpace(ac.Term)
if name != "" {
return name
}
return ""
}
type atomCategories []atomCategory
// CategoryNames returns sorted and deduplicated category names.
func (ac atomCategories) CategoryNames() []string {
return makeSorted(atomCategory.name, ac)
}
func makeSorted[I any, O cmp.Ordered](fn func(I) O, values []I) []O {
var zero O
sorted := make([]O, 0, len(values))
for _, in := range values {
out := fn(in)
if out == zero {
continue
}
where, found := slices.BinarySearch(sorted, out)
if found {
continue
}
// Insert sorted to avoid duplicates.
sorted = slices.Insert(sorted, where, out)
}
return sorted
}
+1 -1
View File
@@ -27,6 +27,6 @@ func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error)
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
}
adapter := &atom10Adapter{atomFeed}
return adapter.BuildFeed(baseURL), nil
return adapter.buildFeed(baseURL), nil
}
}
@@ -53,6 +53,15 @@ func NewRequestBuilder() *RequestBuilder {
}
}
// Clone returns an independent copy of the builder. Mutating the copy (for
// example to disable redirects for a single request) leaves the original
// untouched.
func (r *RequestBuilder) Clone() *RequestBuilder {
clone := *r
clone.headers = r.headers.Clone()
return &clone
}
func (r *RequestBuilder) WithHeader(key, value string) *RequestBuilder {
r.headers.Set(key, value)
return r
@@ -267,6 +267,29 @@ func TestRequestBuilder_WithoutRedirects(t *testing.T) {
}
}
func TestRequestBuilder_Clone(t *testing.T) {
original := NewRequestBuilder().WithHeader("X-Shared", "value")
clone := original.Clone().WithoutRedirects()
clone.WithHeader("X-Clone-Only", "value")
if original.withoutRedirects {
t.Error("Mutating the clone should not disable redirects on the original")
}
if original.headers.Get("X-Clone-Only") != "" {
t.Error("Mutating the clone's headers should not affect the original")
}
if clone.headers.Get("X-Shared") != "value" {
t.Error("Expected the clone to inherit the original headers")
}
if clone.clientTimeout != original.clientTimeout {
t.Error("Expected the clone to inherit the original timeout")
}
}
func TestRequestBuilder_DisableHTTP2(t *testing.T) {
builder := NewRequestBuilder()
builder = builder.DisableHTTP2(true)
+51 -24
View File
@@ -29,6 +29,8 @@ import (
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"miniflux.app/v2/internal/model"
@@ -41,6 +43,34 @@ type filterRule struct {
type filterRules []filterRule
const maxCachedRegexes = 1024
var (
compiledRegexesCache sync.Map
compiledRegexesCacheSize atomic.Int64
)
func cachedRegex(pattern string) *regexp.Regexp {
if v, ok := compiledRegexesCache.Load(pattern); ok {
return v.(*regexp.Regexp)
}
re, err := regexp.Compile(pattern)
if err != nil {
slog.Warn("Failed on regexp compilation",
slog.String("regex_pattern", pattern),
slog.Any("error", err),
)
}
compiledRegexesCache.Store(pattern, re)
if compiledRegexesCacheSize.Add(1) >= maxCachedRegexes {
compiledRegexesCache.Clear()
compiledRegexesCacheSize.Store(0)
}
return re
}
func ParseRules(userRules, feedRules string) filterRules {
rules := make(filterRules, 0)
for line := range strings.SplitSeq(strings.TrimSpace(userRules), "\n") {
@@ -103,12 +133,8 @@ func matchesEntryRegexRules(regexPattern string, feed *model.Feed, entry *model.
return false, true // No pattern means rule is valid but doesn't match
}
compiledRegex, err := regexp.Compile(regexPattern)
if err != nil {
slog.Warn("Failed on regexp compilation",
slog.String("regex_pattern", regexPattern),
slog.Any("error", err),
)
compiledRegex := cachedRegex(regexPattern)
if compiledRegex == nil {
return false, false // Invalid regex pattern
}
@@ -151,26 +177,28 @@ func matchesEntryFilterRules(rules filterRules, feed *model.Feed, entry *model.E
}
func matchesRule(rule filterRule, entry *model.Entry) bool {
switch rule.Type {
case "EntryDate":
if rule.Type == "EntryDate" {
return isDateMatchingPattern(rule.Value, entry.Date)
}
re := cachedRegex(rule.Value)
if re == nil {
return false
}
switch rule.Type {
case "EntryTitle":
match, _ := regexp.MatchString(rule.Value, entry.Title)
return match
return re.MatchString(entry.Title)
case "EntryURL":
match, _ := regexp.MatchString(rule.Value, entry.URL)
return match
return re.MatchString(entry.URL)
case "EntryCommentsURL":
match, _ := regexp.MatchString(rule.Value, entry.CommentsURL)
return match
return re.MatchString(entry.CommentsURL)
case "EntryContent":
match, _ := regexp.MatchString(rule.Value, entry.Content)
return match
return re.MatchString(entry.Content)
case "EntryAuthor":
match, _ := regexp.MatchString(rule.Value, entry.Author)
return match
return re.MatchString(entry.Author)
case "EntryTag":
return containsRegexPattern(rule.Value, entry.Tags)
return slices.ContainsFunc(entry.Tags, re.MatchString)
}
return false
@@ -227,12 +255,11 @@ func isDateMatchingPattern(pattern string, entryDate time.Time) bool {
}
func containsRegexPattern(pattern string, items []string) bool {
for _, item := range items {
if matched, _ := regexp.MatchString(pattern, item); matched {
return true
}
re := cachedRegex(pattern)
if re == nil {
return false
}
return false
return slices.ContainsFunc(items, re.MatchString)
}
func parseDuration(duration string) (time.Duration, error) {
+25 -36
View File
@@ -95,18 +95,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
slog.String("feed_url", subscription.FeedURL),
)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feedCreationRequest.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
@@ -124,17 +112,17 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feedCreationRequest.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password).
WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(feedCreationRequest.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(feedCreationRequest.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy).
IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates).
DisableHTTP2(feedCreationRequest.DisableHTTP2)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))
defer responseHandler.Close()
@@ -232,22 +220,23 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(originalFeed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(originalFeed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(originalFeed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUsernameAndPassword(originalFeed.Username, originalFeed.Password).
WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(originalFeed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(originalFeed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(originalFeed.FetchViaProxy).
IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates).
DisableHTTP2(originalFeed.DisableHTTP2)
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
if !ignoreHTTPCache {
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
requestBuilder = requestBuilder.
WithETag(originalFeed.EtagHeader).
WithLastModified(originalFeed.LastModifiedHeader)
}
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
+10 -10
View File
@@ -26,16 +26,16 @@ func NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {
}
func (c *iconChecker) UpdateOrCreateFeedIcon() {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(c.feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(c.feed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(c.feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(c.feed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(c.feed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(c.feed.FetchViaProxy).
IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates).
DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := newIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
if icon, err := iconFinder.findIcon(); err != nil {
+28 -8
View File
@@ -3,7 +3,10 @@
package itunes // import "miniflux.app/v2/internal/reader/itunes"
import "strings"
import (
"iter"
"strings"
)
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
type ItunesChannelElement struct {
@@ -22,15 +25,16 @@ type ItunesChannelElement struct {
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
}
func (i *ItunesChannelElement) GetItunesCategories() []string {
categories := make([]string, 0, len(i.ItunesCategories))
for _, category := range i.ItunesCategories {
categories = append(categories, category.Text)
if category.SubCategory != nil {
categories = append(categories, category.SubCategory.Text)
func (i *ItunesChannelElement) ItunesCategoriesSeq() iter.Seq[string] {
return func(yield func(string) bool) {
for _, category := range i.ItunesCategories {
for text := range category.All() {
if !yield(text) {
return
}
}
}
}
return categories
}
type ItunesItemElement struct {
@@ -56,6 +60,22 @@ type ItunesCategoryElement struct {
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
}
// All returns iterator for all category names including every nested [ItunesCategoryElement.SubCategory].
func (cat *ItunesCategoryElement) All() iter.Seq[string] {
return func(yield func(string) bool) {
for ; cat != nil; cat = cat.SubCategory {
text := strings.TrimSpace(cat.Text)
if text == "" {
continue
}
if !yield(text) {
return
}
}
}
}
type ItunesOwnerElement struct {
Name string `xml:"name"`
Email string `xml:"email"`
+88 -60
View File
@@ -4,6 +4,7 @@
package json // import "miniflux.app/v2/internal/reader/json"
import (
"cmp"
"log/slog"
"slices"
"strings"
@@ -56,32 +57,34 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
// Populate the icon URL if present.
for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
iconURL = strings.TrimSpace(iconURL)
if iconURL != "" {
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
feed.IconURL = absoluteIconURL
break
}
if iconURL = strings.TrimSpace(iconURL); iconURL == "" {
continue
}
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
feed.IconURL = absoluteIconURL
break
}
}
for _, item := range j.jsonFeed.Items {
entry := model.NewEntry()
entry.Title = strings.TrimSpace(item.Title)
for _, itemURL := range []string{item.URL, item.ExternalURL} {
itemURL = strings.TrimSpace(itemURL)
if itemURL != "" {
// Make sure the entry URL is absolute.
if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
entry.URL = entryURL
}
if itemURL = strings.TrimSpace(itemURL); itemURL == "" {
continue
}
// Make sure the entry URL is absolute.
if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
entry.URL = entryURL
break
}
}
// The entry title is optional, so we need to find a fallback.
entry.Title = strings.TrimSpace(item.Title)
if entry.Title == "" {
// The entry title is optional, so we need to find a fallback.
for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
if value = sanitizer.TruncateHTML(value, 100); value == "" {
continue
@@ -99,75 +102,74 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
// Populate the entry content.
for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
value = strings.TrimSpace(value)
if value != "" {
entry.Content = value
break
if value = strings.TrimSpace(value); value == "" {
continue
}
entry.Content = value
break
}
// Populate the entry date.
for _, value := range []string{item.DatePublished, item.DateModified} {
value = strings.TrimSpace(value)
if value != "" {
if date, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = date
break
}
if value = strings.TrimSpace(value); value == "" {
continue
}
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
continue
}
entry.Date = parsedDate
break
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate the entry author.
itemAuthors := j.jsonFeed.Authors
itemAuthors = append(itemAuthors, item.Authors...)
itemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)
authorNames := make([]string, 0, len(j.jsonFeed.Authors)+len(item.Authors)+1+1)
var authorNames = make([]string, 0, len(itemAuthors))
for _, author := range itemAuthors {
authorName := strings.TrimSpace(author.Name)
if authorName != "" {
authorNames = append(authorNames, authorName)
}
}
authorNames = appendSorted(authorNames, JSONAuthor.name, j.jsonFeed.Authors...)
authorNames = appendSorted(authorNames, JSONAuthor.name, item.Authors...)
authorNames = appendSorted(authorNames, JSONAuthor.name, item.Author, j.jsonFeed.Author)
slices.Sort(authorNames)
authorNames = slices.Compact(authorNames)
entry.Author = strings.Join(authorNames, ", ")
// Populate the entry enclosures.
for _, attachment := range item.Attachments {
attachmentURL := strings.TrimSpace(attachment.URL)
if attachmentURL != "" {
if absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteAttachmentURL,
MimeType: attachment.MimeType,
Size: attachment.Size,
})
}
if attachmentURL == "" {
continue
}
absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL)
if err != nil {
slog.Debug("Unable to build absolute URL for attachment",
slog.String("url", attachmentURL),
slog.String("site_url", feed.SiteURL),
slog.Any("error", err),
)
continue
}
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteAttachmentURL,
MimeType: attachment.MimeType,
Size: attachment.Size,
})
}
// Populate the entry tags.
for _, tag := range item.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
entry.Tags = append(entry.Tags, tag)
}
}
// Sort and deduplicate tags.
slices.Sort(entry.Tags)
entry.Tags = slices.Compact(entry.Tags)
entry.Tags = make([]string, 0, len(item.Tags))
entry.Tags = appendSorted(entry.Tags, strings.TrimSpace, item.Tags...)
// Generate a hash for the entry.
for _, value := range []string{item.ID, item.URL, item.ExternalURL, item.ContentText + item.ContentHTML + item.Summary} {
@@ -183,3 +185,29 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
return feed
}
// appendSortedSeq appends elements from "values" slice into "sorted" slice.
// - "fn" applied to every element of "values"
// - elements inserted into "sorted" slice so it stays sorted
// - duplicate elements are not inserted
func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
var zero O
sorted = slices.Grow(sorted, len(values))
for in := range slices.Values(values) {
out := fn(in)
if out == zero {
continue
}
where, found := slices.BinarySearch(sorted, out)
if found {
continue
}
// Insert sorted to avoid duplicates.
sorted = slices.Insert(sorted, where, out)
}
return sorted
}
+8 -1
View File
@@ -3,7 +3,10 @@
package json // import "miniflux.app/v2/internal/reader/json"
import "encoding/json"
import (
"encoding/json"
"strings"
)
// JSON Feed specs:
// https://www.jsonfeed.org/version/1.1/
@@ -64,6 +67,10 @@ type JSONAuthor struct {
AvatarURL string `json:"avatar"`
}
func (a JSONAuthor) name() string {
return strings.TrimSpace(a.Name)
}
// JSONAuthors unmarshals either an array or a single author object.
// Some feeds incorrectly use an object for "authors"; we accept it to avoid failing the whole feed.
type JSONAuthors []JSONAuthor
+8 -7
View File
@@ -4,6 +4,7 @@
package media // import "miniflux.app/v2/internal/reader/media"
import (
"iter"
"regexp"
"strconv"
"strings"
@@ -174,15 +175,15 @@ func (dl DescriptionList) First() string {
type MediaCategoryList []MediaCategory
func (mcl MediaCategoryList) Labels() []string {
var labels []string
for _, category := range mcl {
label := strings.TrimSpace(category.Label)
if label != "" {
labels = append(labels, label)
func (mcl MediaCategoryList) LabelsSeq() iter.Seq[string] {
return func(yield func(string) bool) {
for _, category := range mcl {
label := strings.TrimSpace(category.Label)
if !yield(label) {
return
}
}
}
return labels
}
type MediaCategory struct {
+3 -3
View File
@@ -43,9 +43,9 @@ func extractBilibiliVideoID(websiteURL string) (string, string, error) {
}
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
if extractErr != nil {
+20 -20
View File
@@ -50,16 +50,16 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
slog.Int64("feed_id", feed.ID),
)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(feed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(feed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(feed.FetchViaProxy).
IgnoreTLSErrors(feed.AllowSelfSignedCertificates).
DisableHTTP2(feed.DisableHTTP2)
// Processing older entries first ensures that their creation timestamp is lower than newer entries.
for _, entry := range slices.Backward(feed.Entries) {
@@ -181,16 +181,16 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
startTime := time.Now()
entry.URL = rewrite.RewriteEntryURL(feed, entry)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(feed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(feed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(feed.FetchViaProxy).
IgnoreTLSErrors(feed.AllowSelfSignedCertificates).
DisableHTTP2(feed.DisableHTTP2)
webpageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
requestBuilder,
+3 -3
View File
@@ -19,9 +19,9 @@ import (
)
func fetchWatchTime(websiteURL, query string, isoDate bool) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
+3 -3
View File
@@ -92,9 +92,9 @@ func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Dura
RawQuery: apiQuery.Encode(),
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
defer responseHandler.Close()
+147 -122
View File
@@ -4,7 +4,9 @@
package rss // import "miniflux.app/v2/internal/reader/rss"
import (
"cmp"
"html"
"iter"
"log/slog"
"path"
"slices"
@@ -36,14 +38,16 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
feed.SiteURL = absoluteSiteURL
}
// Try to find the feed URL from the Atom links.
for _, atomLink := range r.rss.Channel.Links {
atomLinkHref := strings.TrimSpace(atomLink.Href)
if atomLinkHref != "" && atomLink.Rel == "self" {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {
feed.FeedURL = absoluteFeedURL
break
}
// Try to find the feed URL from the Channel links.
for _, link := range r.rss.Channel.Links {
href := strings.TrimSpace(link.Href)
if href == "" || link.Rel != "self" {
continue
}
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, href); err == nil {
feed.FeedURL = absoluteFeedURL
break
}
}
@@ -78,19 +82,20 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
// Populate the entry URL.
entryURL := findEntryURL(&item)
if entryURL == "" {
if entryURL != "" {
entry.URL = entryURL
if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {
entry.URL = absoluteEntryURL
}
}
if entry.URL == "" {
// Fallback to the feed URL if no entry URL is found.
entry.URL = feed.SiteURL
// Fallback to the first enclosure URL if it exists.
if len(entry.Enclosures) > 0 && entry.Enclosures[0].URL != "" {
entry.URL = entry.Enclosures[0].URL
} else {
// Fallback to the feed URL if no entry URL is found.
entry.URL = feed.SiteURL
}
} else {
if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {
entry.URL = absoluteEntryURL
} else {
entry.URL = entryURL
}
}
@@ -150,9 +155,6 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
if len(entry.Tags) == 0 {
entry.Tags = findFeedTags(&r.rss.Channel)
}
// Sort and deduplicate tags.
slices.Sort(entry.Tags)
entry.Tags = slices.Compact(entry.Tags)
feed.Entries = append(feed.Entries, entry)
}
@@ -181,26 +183,12 @@ func findFeedAuthor(rssChannel *rssChannel) string {
}
func findFeedTags(rssChannel *rssChannel) []string {
itunesCategories := rssChannel.GetItunesCategories()
tags := make([]string, 0, len(rssChannel.Categories)+len(itunesCategories)+1)
tags := make([]string, 0, len(rssChannel.Categories)+2*len(rssChannel.ItunesCategories)+1)
for _, tag := range rssChannel.Categories {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
tags = appendSorted(tags, strings.TrimSpace, rssChannel.Categories...)
tags = appendSortedSeq(tags, strings.TrimSpace, rssChannel.ItunesCategoriesSeq())
for _, tag := range itunesCategories {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
if tag := strings.TrimSpace(rssChannel.GooglePlayCategory.Text); tag != "" {
tags = append(tags, tag)
}
tags = appendSorted(tags, strings.TrimSpace, rssChannel.GooglePlayCategory.Text)
return tags
}
@@ -259,21 +247,21 @@ func findEntryDate(rssItem *rssItem) time.Time {
value = rssItem.DublinCoreDate
}
if value != "" {
result, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from RSS feed",
slog.String("date", value),
slog.String("guid", rssItem.GUID.Data),
slog.Any("error", err),
)
return time.Now()
}
return result
if value = strings.TrimSpace(value); value == "" {
return time.Now()
}
return time.Now()
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from RSS feed",
slog.String("date", value),
slog.String("guid", rssItem.GUID.Data),
slog.Any("error", err),
)
return time.Now()
}
return parsedDate
}
func findEntryAuthor(rssItem *rssItem) string {
@@ -300,22 +288,10 @@ func findEntryAuthor(rssItem *rssItem) string {
}
func findEntryTags(rssItem *rssItem) []string {
mediaLabels := rssItem.MediaCategories.Labels()
tags := make([]string, 0, len(rssItem.Categories)+len(mediaLabels))
tags := make([]string, 0, len(rssItem.Categories)+len(rssItem.MediaCategories))
for _, tag := range rssItem.Categories {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
for _, tag := range mediaLabels {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
tags = appendSorted(tags, strings.TrimSpace, rssItem.Categories...)
tags = appendSortedSeq(tags, strings.TrimSpace, rssItem.MediaCategories.LabelsSeq())
return tags
}
@@ -333,22 +309,28 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
if mediaURL == "" {
continue
}
if _, found := duplicates[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
duplicates[mediaAbsoluteURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
continue
}
if _, found := duplicates[mediaURL]; found {
continue
}
duplicates[mediaURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
for _, enclosure := range rssItem.Enclosures {
@@ -370,15 +352,17 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
enclosureURL = absoluteEnclosureURL
}
if _, found := duplicates[enclosureURL]; !found {
duplicates[enclosureURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: enclosureURL,
MimeType: enclosure.Type,
Size: enclosure.Size(),
})
if _, found := duplicates[enclosureURL]; found {
continue
}
duplicates[enclosureURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: enclosureURL,
MimeType: enclosure.Type,
Size: enclosure.Size(),
})
}
for _, mediaContent := range mediaContents {
@@ -386,23 +370,28 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
if mediaURL == "" {
continue
}
if _, found := duplicates[mediaURL]; !found {
mediaURL := strings.TrimSpace(mediaContent.URL)
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
duplicates[mediaAbsoluteURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
continue
}
if _, found := duplicates[mediaURL]; found {
continue
}
duplicates[mediaURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
for _, mediaPeerLink := range mediaPeerLinks {
@@ -410,24 +399,60 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
if mediaURL == "" {
continue
}
if _, found := duplicates[mediaURL]; !found {
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
duplicates[mediaAbsoluteURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
continue
}
if _, found := duplicates[mediaURL]; found {
continue
}
duplicates[mediaURL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
return enclosures
}
// appendSorted is identical to [appendSortedSeq] except receives variadic values rather than [iter.Seq].
func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
sorted = slices.Grow(sorted, len(values))
return appendSortedSeq(sorted, fn, slices.Values(values))
}
// appendSortedSeq appends elements from "values" iterator into "sorted" slice.
// - "fn" applied to every element of "values"
// - elements inserted into "sorted" slice so it stays sorted
// - duplicate elements are not inserted
func appendSortedSeq[I any, O cmp.Ordered](sorted []O, fn func(I) O, values iter.Seq[I]) []O {
var zero O
for in := range values {
out := fn(in)
if out == zero {
continue
}
where, found := slices.BinarySearch(sorted, out)
if found {
continue
}
// Insert sorted to avoid duplicates.
sorted = slices.Insert(sorted, where, out)
}
return sorted
}
+1 -2
View File
@@ -1687,7 +1687,7 @@ func TestParseEntryWithMediaContent(t *testing.T) {
if len(feed.Entries) != 1 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if len(feed.Entries[0].Enclosures) != 4 {
if len(feed.Entries[0].Enclosures) != 3 {
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
}
@@ -1696,7 +1696,6 @@ func TestParseEntryWithMediaContent(t *testing.T) {
mimeType string
size int64
}{
{"https://example.org/thumbnail.jpg", "image/*", 0},
{"https://example.org/thumbnail.jpg", "image/*", 0},
{"https://example.org/media1.jpg", "image/*", 0},
{"https://example.org/media2.jpg", "image/*", 0},
+7 -17
View File
@@ -4,7 +4,6 @@
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import (
"errors"
"io"
"net/url"
"slices"
@@ -18,10 +17,6 @@ import (
"golang.org/x/net/html"
)
const (
maxDepth = 512 // The maximum allowed depths for nested HTML tags, same was WebKit.
)
var (
allowedHTMLTagsAndAttributes = map[string][]string{
"a": {"href", "title", "id"},
@@ -197,8 +192,7 @@ func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) s
// Errors are a non-issue, so they're handled in filterAndRenderHTML
parsedBaseUrl, _ := url.Parse(baseURL)
for c := body.FirstChild; c != nil; c = c.NextSibling {
// -2 because of `<html><body>…`
if err := filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions, maxDepth-2); err != nil {
if err := filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions); err != nil {
return ""
}
}
@@ -224,15 +218,11 @@ func findAllowedIframeSourceDomain(iframeSourceURL string) (string, bool) {
return "", false
}
func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {
func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) error {
if n == nil {
return nil
}
if depth == 0 {
return errors.New("maximum nested tags limit reached")
}
switch n.Type {
case html.TextNode:
buf.WriteString(html.EscapeString(n.Data))
@@ -245,7 +235,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
_, ok := allowedHTMLTagsAndAttributes[tag]
if !ok {
// The tag isn't allowed, but we're still interested in its content
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
}
htmlAttributes, hasAllRequiredAttributes := sanitizeAttributes(parsedBaseUrl, tag, n.Attr, sanitizerOptions)
@@ -255,7 +245,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
return nil
}
// The tag doesn't have every required attributes but we're still interested in its content
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
}
buf.WriteByte('<')
buf.WriteString(n.Data)
@@ -271,7 +261,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
if tag != "iframe" {
// iframes aren't allowed to have child nodes.
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
}
buf.WriteString("</")
@@ -282,9 +272,9 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
return nil
}
func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {
func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) error {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if err := filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions, depth); err != nil {
if err := filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions); err != nil {
return err
}
}
+4 -3
View File
@@ -1011,9 +1011,10 @@ func TestAttrLowerCase(t *testing.T) {
}
func TestDeeplyNestedpage(t *testing.T) {
maxDepth := 512 // html.Parse has a maximum depth of 512
input := "test"
// -3 instead of -1 because <html><body> is automatically added.
for range maxDepth - 3 {
// -2 instead of -1 because <html><body> is automatically added.
for range maxDepth - 2 {
input = "<div>" + input + "</div>"
}
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)
@@ -1024,7 +1025,7 @@ func TestDeeplyNestedpage(t *testing.T) {
}
input = "test"
for range maxDepth - 2 {
for range maxDepth - 1 {
input = "<div>" + input + "</div>"
}
output = sanitizeHTMLWithDefaultOptions("http://example.org/", input)
+5 -2
View File
@@ -221,9 +221,12 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
// Some websites redirects unknown URLs to the home page.
// As result, the list of known URLs is returned to the subscription list.
// We don't want the user to choose between invalid feed URLs.
f.requestBuilder.WithoutRedirects()
//
// Probe each known URL on its own builder so disabling redirects
// here doesn't leak into the finder's other requests.
requestBuilder := f.requestBuilder.Clone().WithoutRedirects()
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(fullURL))
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(fullURL))
localizedError := responseHandler.LocalizedError()
responseHandler.Close()
+17 -7
View File
@@ -117,14 +117,24 @@ func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model
c.user_id,
c.title,
c.hide_globally,
(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count,
(SELECT count(*)
FROM feeds
JOIN entries ON (feeds.id = entries.feed_id)
WHERE feeds.category_id = c.id AND entries.status = $1) AS count_unread
coalesce(fc.feed_count, 0),
coalesce(uc.unread_count, 0)
FROM categories c
LEFT JOIN (
SELECT category_id, count(*) AS feed_count
FROM feeds
WHERE user_id = $2
GROUP BY category_id
) fc ON fc.category_id = c.id
LEFT JOIN (
SELECT f.category_id, count(*) AS unread_count
FROM entries e
INNER JOIN feeds f ON f.id = e.feed_id
WHERE e.user_id = $2 AND e.status = $1
GROUP BY f.category_id
) uc ON uc.category_id = c.id
WHERE
user_id=$2
c.user_id=$2
`
if sortOrder == "alphabetical" {
@@ -135,7 +145,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model
} else {
query += `
ORDER BY
count_unread DESC,
coalesce(uc.unread_count, 0) DESC,
c.title ASC
`
}
+10 -7
View File
@@ -14,8 +14,8 @@ import (
"github.com/lib/pq"
)
// GetEnclosures returns all attachments for the given entry.
func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
// EnclosuresByEntryID returns all enclosures for the given entry.
func (s *Storage) EnclosuresByEntryID(entryID int64) (model.EnclosureList, error) {
query := `
SELECT
id,
@@ -61,7 +61,8 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
return enclosures, nil
}
func (s *Storage) GetEnclosuresForEntries(entryIDs []int64) (map[int64]model.EnclosureList, error) {
// EnclosuresByEntryIDs returns enclosures for the given entries, grouped by entry ID.
func (s *Storage) EnclosuresByEntryIDs(entryIDs []int64) (map[int64]model.EnclosureList, error) {
query := `
SELECT
id,
@@ -106,7 +107,8 @@ func (s *Storage) GetEnclosuresForEntries(entryIDs []int64) (map[int64]model.Enc
return enclosuresMap, nil
}
func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
// EnclosureByID returns the enclosure for the given user and enclosure ID.
func (s *Storage) EnclosureByID(userID, enclosureID int64) (*model.Enclosure, error) {
query := `
SELECT
id,
@@ -119,10 +121,10 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
FROM
enclosures
WHERE
id = $1
id = $1 AND user_id = $2
`
row := s.db.QueryRow(query, enclosureID)
row := s.db.QueryRow(query, enclosureID, userID)
var enclosure model.Enclosure
err := row.Scan(
@@ -155,7 +157,7 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
(url, size, mime_type, entry_id, user_id, media_progression)
VALUES
($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING
ON CONFLICT (user_id, entry_id, encode(sha256(url::bytea), 'hex')) DO NOTHING
RETURNING
id
`
@@ -215,6 +217,7 @@ func (s *Storage) updateEnclosures(tx *sql.Tx, entry *model.Entry) error {
return nil
}
// UpdateEnclosure persists changes to the given enclosure.
func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
query := `
UPDATE
+1 -11
View File
@@ -451,20 +451,10 @@ func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64
// SetEntriesStarredState updates the starred state for the given list of entries.
func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
if err != nil {
if _, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs)); err != nil {
return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
}
count, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)
}
if count == 0 {
return errors.New(`store: nothing has been updated`)
}
return nil
}
+1 -1
View File
@@ -27,7 +27,7 @@ type entryPaginationBuilder struct {
// WithSearchQuery adds full-text search query to the condition.
func (e *entryPaginationBuilder) WithSearchQuery(query string) *entryPaginationBuilder {
if query != "" {
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", len(e.args)+1))
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ websearch_to_tsquery($%d)", len(e.args)+1))
e.args = append(e.args, query)
}
+30 -8
View File
@@ -46,13 +46,13 @@ func (e *EntryQueryBuilder) WithoutContent() *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
if query != "" {
nArgs := len(e.args) + 1
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", nArgs))
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ websearch_to_tsquery($%d)", nArgs))
e.args = append(e.args, query)
// 0.0000001 = 0.1 / (seconds_in_a_day)
e.sortExpressions = append(e.sortExpressions,
fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
fmt.Sprintf("ts_rank(document_vectors, websearch_to_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
)
}
return e
@@ -207,6 +207,14 @@ func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {
return e
}
// WithLimitAndMaximum sets the limit, capped at the given maximum.
func (e *EntryQueryBuilder) WithLimitAndMaximum(limit, maximum int) *EntryQueryBuilder {
if limit > 0 {
e.limit = min(limit, maximum)
}
return e
}
// WithOffset set the offset.
func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder {
if offset > 0 {
@@ -250,7 +258,7 @@ func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) {
return nil, nil
}
entries[0].Enclosures, err = e.store.GetEnclosures(entries[0].ID)
entries[0].Enclosures, err = e.store.EnclosuresByEntryID(entries[0].ID)
if err != nil {
return nil, err
}
@@ -425,7 +433,7 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
}
if e.fetchEnclosures && len(entryIDs) > 0 {
enclosures, err := e.store.GetEnclosuresForEntries(entryIDs)
enclosures, err := e.store.EnclosuresByEntryIDs(entryIDs)
if err != nil {
return nil, 0, fmt.Errorf("store: unable to fetch enclosures: %w", err)
}
@@ -462,18 +470,32 @@ func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
var entryIDs []int64
for rows.Next() {
var entryID int64
err := rows.Scan(&entryID)
if err != nil {
if err := rows.Scan(&entryID); err != nil {
return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
}
entryIDs = append(entryIDs, entryID)
}
return entryIDs, nil
}
// GetEntryIDsWithCount returns a list of entry IDs and the total count of
// matching rows (ignoring limit/offset). It uses two queries: one to count
// all matching rows and one to fetch the paginated IDs.
func (e *EntryQueryBuilder) GetEntryIDsWithCount() ([]int64, int, error) {
total, err := e.CountEntries()
if err != nil {
return nil, 0, err
}
entryIDs, err := e.GetEntryIDs()
if err != nil {
return nil, 0, err
}
return entryIDs, total, nil
}
func (e *EntryQueryBuilder) contentColumn() string {
if e.excludeContent {
return "'' AS content"
+4
View File
@@ -16,6 +16,8 @@ import (
"golang.org/x/crypto/bcrypt"
)
var dummyBcryptHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
// CountUsers returns the total number of users.
func (s *Storage) CountUsers() (int, error) {
var result int
@@ -673,6 +675,8 @@ func (s *Storage) CheckPassword(username, password string) error {
err := s.db.QueryRow("SELECT password FROM users WHERE username=$1", username).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
// Perform a dummy bcrypt comparison against the hashed `password` string to avoid leaking whether the user exists via response timing.
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
return fmt.Errorf(`store: unable to find this user: %s`, username)
} else if err != nil {
return fmt.Errorf(`store: unable to fetch user: %v`, err)
+1 -1
View File
@@ -28,7 +28,7 @@ type Engine struct {
func NewEngine(basePath string) *Engine {
return &Engine{
templates: make(map[string]*template.Template),
funcMap: &funcMap{basePath},
funcMap: &funcMap{basePath: basePath},
}
}
+8 -5
View File
@@ -27,11 +27,17 @@ import (
)
type funcMap struct {
basePath string
basePath string
iconPaths map[string]string
}
// Map returns a map of template functions that are compiled during template parsing.
func (f *funcMap) Map() template.FuncMap {
// Pre-compute every icon URL once, as iconPath is called a lot during pages rendering.
f.iconPaths = make(map[string]string, len(static.BinaryBundles))
for filename, bundle := range static.BinaryBundles {
f.iconPaths[filename] = f.basePath + "/icon/" + bundle.Checksum + "/" + filename
}
return template.FuncMap{
"contains": strings.Contains,
"csp": csp,
@@ -155,10 +161,7 @@ func (f *funcMap) Map() template.FuncMap {
}
func (f *funcMap) iconPath(filename string) string {
if bundle, ok := static.BinaryBundles[filename]; ok {
return fmt.Sprintf("%s/icon/%s/%s", f.basePath, bundle.Checksum, filename)
}
return fmt.Sprintf("%s/icon/_/%s", f.basePath, filename)
return f.iconPaths[filename]
}
func (f *funcMap) iconFunc() func(string) template.HTML {
@@ -3,7 +3,7 @@
{{ range .feeds }}
<article
class="item feed-item {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ else if ne .UnreadCount 0 }}feed-has-unread{{ end }}"
aria-labelledby="feed-title-{{ .ID }} feed-entries-counter"
aria-labelledby="feed-title-{{ .ID }} feed-entries-counter-{{ .ID }}"
tabindex="-1"
>
<header class="item-header" dir="auto">
@@ -16,7 +16,7 @@
{{ .Title }}
</a>
</h2>
<span id="feed-entries-counter" class="feed-entries-counter">
<span id="feed-entries-counter-{{ .ID }}" class="feed-entries-counter">
<span aria-hidden="true">(</span>
<span class="sr-only">{{ plural "page.unread_entry_count" .UnreadCount .UnreadCount }}</span>
<span aria-hidden="true">{{ .UnreadCount }} /</span>
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
+2 -2
View File
@@ -56,9 +56,9 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
WithSearchQuery(searchQuery)
if unreadOnly {
if entry.Status == model.EntryStatusRead {
entryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)
entryPaginationBuilder = entryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)
} else {
entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
entryPaginationBuilder = entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
}
}
+11 -9
View File
@@ -54,15 +54,17 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent())
feedModificationRequest := &model.FeedModificationRequest{
FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title),
Description: model.OptionalString(feedForm.Description),
CategoryID: model.OptionalNumber(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
ProxyURL: model.OptionalString(feedForm.ProxyURL),
FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title),
Description: model.OptionalString(feedForm.Description),
CategoryID: model.OptionalNumber(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
ProxyURL: model.OptionalString(feedForm.ProxyURL),
BlockFilterEntryRules: model.OptionalString(feedForm.BlockFilterEntryRules),
KeepFilterEntryRules: model.OptionalString(feedForm.KeepFilterEntryRules),
}
if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feed.ID, feedModificationRequest); validationErr != nil {
+13 -1
View File
@@ -57,10 +57,22 @@ func (s *SubscriptionForm) Validate() *locale.LocalizedError {
return locale.NewLocalizedError("error.feed_invalid_urlrewrite_rule")
}
if s.ProxyURL != "" && !urllib.IsAbsoluteURL(s.ProxyURL) {
if s.ProxyURL != "" && !urllib.IsValidProxyURL(s.ProxyURL) {
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
}
if s.BlockFilterEntryRules != "" {
if err := validator.IsValidFilterRules(s.BlockFilterEntryRules, "block"); err != nil {
return err
}
}
if s.KeepFilterEntryRules != "" {
if err := validator.IsValidFilterRules(s.KeepFilterEntryRules, "keep"); err != nil {
return err
}
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package form // import "miniflux.app/v2/internal/ui/form"
import "testing"
func TestSubscriptionFormValidateInvalidBlockFilterRules(t *testing.T) {
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, BlockFilterEntryRules: "BadField=foo"}
if err := s.Validate(); err == nil {
t.Error("Validate should return an error for an invalid block filter rule")
}
}
func TestSubscriptionFormValidateInvalidKeepFilterRules(t *testing.T) {
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, KeepFilterEntryRules: "BadField=foo"}
if err := s.Validate(); err == nil {
t.Error("Validate should return an error for an invalid keep filter rule")
}
}
func TestSubscriptionFormValidateValidFilterRules(t *testing.T) {
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, BlockFilterEntryRules: "EntryTitle=add"}
if err := s.Validate(); err != nil {
t.Errorf("Validate should not return an error for a valid filter rule, got: %v", err)
}
}
+3 -3
View File
@@ -90,9 +90,9 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
view.Set("countUnread", navMetadata.CountUnread)
view.Set("countErrorFeeds", navMetadata.CountErrorFeeds)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(opmlFileURL))
defer responseHandler.Close()
+5 -7
View File
@@ -86,20 +86,18 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
slog.String("media_url", mediaURL),
)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.MediaProxyHTTPClientTimeout())
// Disable compression for the media proxy requests (not implemented).
requestBuilder.WithoutCompression()
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.MediaProxyHTTPClientTimeout()).
WithoutCompression() // Disable compression for the media proxy requests (not implemented).
if referer := rewrite.GetRefererForURL(mediaURL); referer != "" {
requestBuilder.WithHeader("Referer", referer)
requestBuilder = requestBuilder.WithHeader("Referer", referer)
}
forwardedRequestHeader := [...]string{"Range", "Accept", "Accept-Encoding", "User-Agent"}
for _, requestHeaderName := range forwardedRequestHeader {
if r.Header.Get(requestHeaderName) != "" {
requestBuilder.WithHeader(requestHeaderName, r.Header.Get(requestHeaderName))
requestBuilder = requestBuilder.WithHeader(requestHeaderName, r.Header.Get(requestHeaderName))
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
WithLimit(user.EntriesPerPage)
if unreadOnly {
builder.WithStatuses(model.EntryStatusUnread)
builder = builder.WithStatuses(model.EntryStatusUnread)
}
entries, entriesCount, err = builder.GetEntriesWithCount()
+18 -9
View File
@@ -658,19 +658,28 @@ function toggleEntryStatus(element, toasting) {
/**
* Handle the refresh of all feeds.
*
* This function POSTs to the URL specified in the data-refresh-all-feeds-url attribute of the body element.
* This submits a real form POST to the URL specified in the data-refresh-all-feeds-url
* attribute of the body element, so the browser follows the redirect once and renders the
* server-side flash message, matching the behavior of the menu button.
*/
function handleRefreshAllFeedsAction() {
const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl;
if (refreshAllFeedsUrl) {
sendPOSTRequest(refreshAllFeedsUrl).then((response) => {
if (response?.redirected && response.url) {
window.location.href = response.url;
} else {
window.location.reload();
}
});
if (!refreshAllFeedsUrl) {
return;
}
const form = document.createElement("form");
form.method = "post";
form.action = refreshAllFeedsUrl;
const csrfField = document.createElement("input");
csrfField.type = "hidden";
csrfField.name = "csrf";
csrfField.value = document.body.dataset.csrfToken || "";
form.appendChild(csrfField);
document.body.appendChild(form);
form.submit();
}
/**
+11 -11
View File
@@ -57,17 +57,17 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
rssBridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionForm.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionForm.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionForm.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionForm.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionForm.Username, subscriptionForm.Password)
requestBuilder.IgnoreTLSErrors(subscriptionForm.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionForm.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(subscriptionForm.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(subscriptionForm.FetchViaProxy).
WithUserAgent(subscriptionForm.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(subscriptionForm.Cookie).
WithUsernameAndPassword(subscriptionForm.Username, subscriptionForm.Password).
IgnoreTLSErrors(subscriptionForm.AllowSelfSignedCertificates).
DisableHTTP2(subscriptionForm.DisableHTTP2)
subscriptionFinder := subscription.NewSubscriptionFinder(requestBuilder)
subscriptions, localizedError := subscriptionFinder.FindSubscriptions(
+17
View File
@@ -43,6 +43,11 @@ func hasHTTPPrefix(inputURL string) bool {
return strings.HasPrefix(inputURL, "https://") || strings.HasPrefix(inputURL, "http://")
}
// hasSOCKSPrefix reports whether the URL string begins with an SOCKS5 or SOCKS5H scheme.
func hasSOCKSPrefix(inputURL string) bool {
return strings.HasPrefix(inputURL, "socks5://") || strings.HasPrefix(inputURL, "socks5h://")
}
// IsAbsoluteURL reports whether the link is absolute and starts with an HTTP or HTTPS scheme.
func IsAbsoluteURL(inputURL string) bool {
if !hasHTTPPrefix(inputURL) {
@@ -55,6 +60,18 @@ func IsAbsoluteURL(inputURL string) bool {
return parsedURL.IsAbs()
}
// IsValidProxyURL reports whether the url is absolute, has a host and starts with an HTTP, HTTPS, SOCKS5 or SOCKS5H scheme.
func IsValidProxyURL(inputURL string) bool {
if !hasHTTPPrefix(inputURL) && !hasSOCKSPrefix(inputURL) {
return false
}
parsedURL, err := url.Parse(inputURL)
if err != nil {
return false
}
return parsedURL.IsAbs() && parsedURL.Host != ""
}
// resolveToAbsoluteURL resolves a relative URL using a base URL, parsing the base only if needed.
func resolveToAbsoluteURL(parsedBaseURL *url.URL, baseURL, relativeURL string) (string, error) {
// Avoid parsing the relative URL if it's already absolute
+28
View File
@@ -68,6 +68,34 @@ func TestIsAbsoluteURL(t *testing.T) {
}
}
func TestIsValidProxyURL(t *testing.T) {
scenarios := map[string]bool{
"http://127.0.0.1:3128": true,
"http://[::1]:1055": true,
"https://proxy.example.org": true,
"socks5://127.0.0.1:1080": true,
"socks5h://127.0.0.1:1080": true,
"socks5://[::1]:1055": true,
"socks5h://[::1]:1055": true,
"socks5://[::1%25eno1]:1055": true,
"ftp://host": false,
"/relative/path": false,
"invalid url": false,
"http://[::1": false,
"http:///var/run/socket": false,
"sock:///socket.file": false,
"socks5://[::1%eno1]:1055": false,
"": false,
}
for input, expected := range scenarios {
actual := IsValidProxyURL(input)
if actual != expected {
t.Errorf(`Unexpected result, got %v instead of %v for %q`, actual, expected, input)
}
}
}
func TestAbsoluteURL(t *testing.T) {
type absoluteScenario struct {
name string
+19
View File
@@ -19,6 +19,25 @@ func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateReques
return ValidateEntryStatus(request.Status)
}
// ValidateEntriesStatusAndStarredUpdateRequest validates a status and/or starred update
// for a list of entries. At least one of the status or starred fields must be specified.
// This is used by the API, which can update the read status, the starred state, or both.
func ValidateEntriesStatusAndStarredUpdateRequest(request *model.EntriesStatusUpdateRequest) error {
if len(request.EntryIDs) == 0 {
return errors.New(`the list of entries cannot be empty`)
}
if request.Status == "" && request.Starred == nil {
return errors.New(`either the status or the starred field must be specified`)
}
if request.Status != "" {
return ValidateEntryStatus(request.Status)
}
return nil
}
// ValidateEntryStatus makes sure the entry status is valid.
func ValidateEntryStatus(status string) error {
switch status {
+68
View File
@@ -34,6 +34,74 @@ func TestValidateEntriesStatusUpdateRequest(t *testing.T) {
}
}
func TestValidateEntriesStatusAndStarredUpdateRequest(t *testing.T) {
err := ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Status: model.EntryStatusRead,
EntryIDs: []int64{int64(123), int64(456)},
})
if err != nil {
t.Error(`A valid request should not be rejected`)
}
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Status: model.EntryStatusRead,
})
if err == nil {
t.Error(`An empty list of entries is not valid`)
}
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Status: "invalid",
EntryIDs: []int64{int64(123)},
})
if err == nil {
t.Error(`Only a valid status should be accepted`)
}
starred := true
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Starred: &starred,
EntryIDs: []int64{int64(123)},
})
if err != nil {
t.Error(`A request with only the starred field should be accepted`)
}
notStarred := false
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Starred: &notStarred,
EntryIDs: []int64{int64(123)},
})
if err != nil {
t.Error(`A request with starred set to false should be accepted`)
}
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Status: model.EntryStatusRead,
Starred: &starred,
EntryIDs: []int64{int64(123)},
})
if err != nil {
t.Error(`A request with both status and starred should be accepted`)
}
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
EntryIDs: []int64{int64(123)},
})
if err == nil {
t.Error(`A request without status or starred should be rejected`)
}
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
Status: "invalid",
Starred: &starred,
EntryIDs: []int64{int64(123)},
})
if err == nil {
t.Error(`An invalid status should be rejected even when starred is specified`)
}
}
func TestValidateEntryStatus(t *testing.T) {
for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
if err := ValidateEntryStatus(status); err != nil {
+6 -6
View File
@@ -37,18 +37,18 @@ func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.F
}
if request.BlockFilterEntryRules != "" {
if err := isValidFilterRules(request.BlockFilterEntryRules, "block"); err != nil {
if err := IsValidFilterRules(request.BlockFilterEntryRules, "block"); err != nil {
return err
}
}
if request.KeepFilterEntryRules != "" {
if err := isValidFilterRules(request.KeepFilterEntryRules, "keep"); err != nil {
if err := IsValidFilterRules(request.KeepFilterEntryRules, "keep"); err != nil {
return err
}
}
if request.ProxyURL != "" && !urllib.IsAbsoluteURL(request.ProxyURL) {
if request.ProxyURL != "" && !urllib.IsValidProxyURL(request.ProxyURL) {
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
}
@@ -106,13 +106,13 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ
}
if request.BlockFilterEntryRules != nil && *request.BlockFilterEntryRules != "" {
if err := isValidFilterRules(*request.BlockFilterEntryRules, "block"); err != nil {
if err := IsValidFilterRules(*request.BlockFilterEntryRules, "block"); err != nil {
return err
}
}
if request.KeepFilterEntryRules != nil && *request.KeepFilterEntryRules != "" {
if err := isValidFilterRules(*request.KeepFilterEntryRules, "keep"); err != nil {
if err := IsValidFilterRules(*request.KeepFilterEntryRules, "keep"); err != nil {
return err
}
}
@@ -122,7 +122,7 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ
return locale.NewLocalizedError("error.proxy_url_not_empty")
}
if !urllib.IsAbsoluteURL(*request.ProxyURL) {
if !urllib.IsValidProxyURL(*request.ProxyURL) {
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"miniflux.app/v2/internal/locale"
)
func isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
func IsValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
// Valid Format: FieldName=RegEx\nFieldName=RegEx...
fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag", "EntryDate"}
+1 -1
View File
@@ -46,7 +46,7 @@ func TestIsValidFilterRules(t *testing.T) {
for _, tt := range tests {
tc := tt
t.Run(tc.name, func(t *testing.T) {
err := isValidFilterRules(tc.rules, "block")
err := IsValidFilterRules(tc.rules, "block")
if (err != nil) != tc.wantErr {
t.Fatalf("expected error=%v, got %v", tc.wantErr, err)
}
+1 -1
View File
@@ -15,7 +15,7 @@ func ValidateSubscriptionDiscovery(request *model.SubscriptionDiscoveryRequest)
return locale.NewLocalizedError("error.invalid_site_url")
}
if request.ProxyURL != "" && !urllib.IsAbsoluteURL(request.ProxyURL) {
if request.ProxyURL != "" && !urllib.IsValidProxyURL(request.ProxyURL) {
return locale.NewLocalizedError("error.invalid_proxy_url")
}
+2 -2
View File
@@ -130,7 +130,7 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
if changes.BlockFilterEntryRules != nil {
if *changes.BlockFilterEntryRules != "" {
if err := isValidFilterRules(*changes.BlockFilterEntryRules, "block"); err != nil {
if err := IsValidFilterRules(*changes.BlockFilterEntryRules, "block"); err != nil {
return err
}
}
@@ -138,7 +138,7 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
if changes.KeepFilterEntryRules != nil {
if *changes.KeepFilterEntryRules != "" {
if err := isValidFilterRules(*changes.KeepFilterEntryRules, "keep"); err != nil {
if err := IsValidFilterRules(*changes.KeepFilterEntryRules, "keep"); err != nil {
return err
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func init() {
if Version == "" {
// Some Miniflux clients expect a specific version format.
// For example, Flux News converts the string version to an integer.
Version = "2.2.x-dev"
Version = "2.3.x-dev"
}
if Commit == "" {
Commit = getCommit()
+2 -2
View File
@@ -546,7 +546,7 @@ a linked OAuth2 account to sign in\&.
Disabled by default\&.
.TP
.B POLLING_FREQUENCY
Interval for the background job scheduler.
Interval in minutes for the background job scheduler.
.br
Determines how often a batch of feeds is selected for refresh,
based on their last refresh time\&.
@@ -625,7 +625,7 @@ Minimum interval in minutes for the round robin scheduler\&.
Default is 60 minutes\&.
.TP
.B TRUSTED_REVERSE_PROXY_NETWORKS
List of networks (CIDR notation) allowed to use the proxy
A comma-separated list of networks (CIDR notation) allowed to use the proxy
authentication header, \fBX-Forwarded-For\fR,
\fBX-Forwarded-Proto\fR, and \fBX-Real-Ip\fR headers\&.
.br