Compare commits

..

74 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
gudvinr 9a774083ab refactor(response): use http.Header for header map
http.Header is built for headers, so use it for its intended purpose
2026-05-28 16:13:04 -07:00
dependabot[bot] 79381e6f9a build(deps): bump the gomod group with 4 updates
Bumps the gomod group with 4 updates: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn), [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/net](https://github.com/golang/net).


Updates `github.com/go-webauthn/webauthn` from 0.17.3 to 0.17.4
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.17.3...v0.17.4)

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

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

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

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.17.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 17:35:18 -07:00
Frédéric Guillot 161ed71eb0 fix(api): forbid setting OAuth identity fields via user update
A non-admin could bind an arbitrary OAuth identity to their own account
by patching google_id or openid_connect_id, bypassing the duplicate
check enforced in the OAuth callback. Remove both fields from the
update request so binding only happens through the OAuth flow.
2026-05-26 21:04:56 -07:00
Frédéric Guillot c896bafdaa fix(urllib): reject backslashes in relative path validation
Browsers normalize backslashes to forward slashes, so a redirect target
like "/\evil.com" parsed as a relative path by url.Parse and resolved to
//evil.com by the browser, resulting in an open redirect. Reject any link
containing a backslash before validating it as a relative path.
2026-05-26 20:06:58 -07:00
gudvinr 66996e3ffa refactor(storage): use same API for list and singular items
Instead of relying on WithX and WithXs just stick to latter with parametrized arguments.
2026-05-26 19:21:25 -07:00
jvoisin f050f23bda refactor(storage): collapse WithTags into a single array-containment predicate
WithTags previously emitted one "LOWER($i) = ANY(LOWER(e.tags::text)::text[])"
condition and one parameter per filter tag. With K tags this means K
predicates, K parameters, and K evaluations of the LOWER(e.tags::text)::text[]
sub-expression per candidate row.

This commit replaces the loop with a single predicate using the array
containment operator:

    LOWER(e.tags::text)::text[] @> LOWER($N::text)::text[]

Same case-insensitive "row must contain all listed tags" semantics, fewer
predicates for the planner, and the row-side expression is referenced once
instead of K times.
2026-05-26 19:08:38 -07:00
jvoisin 50088405e0 perf(reader): walk feed <link> tags in a single DOM pass
findSubscriptionsFromWebPage was running four separate goquery.Find passes, one
per supported MIME type. This commit replaces it with one
doc.Find("link[type]") traversal and a switch on the type attribute, which is
functionally equivalent, but it visits the DOM once instead of four times and
emits results in document order. Microbenchmark on a representative <head> (8
link tags + ~100 unrelated head children):

  before: ~25 µs/op, 1536 B/op, 50 allocs/op
  after:  ~9  µs/op,  744 B/op, 21 allocs/op
2026-05-25 20:01:25 -07:00
gudvinr d235a63138 refactor(sanitizer): always trim spaces in StripTags
It's never used without being trimmed anyway
2026-05-25 18:07:09 -07:00
jvoisin 483da488d8 perf(finder): size findSubscriptionsFromWebPage dedup map 2026-05-24 17:50:27 -07:00
gudvinr e01a6100ca fix(readingtime): make CJK detection more reliable
* division by 50 is 2%, not 50%
* non-letters are often the same between LTR languages
2026-05-24 17:40:08 -07:00
gudvinr a725476164 fix(readingtime): trim CJK text by rune not by bytes
Common mistake when working with UTF-8 is to use sub-slicing for truncate. That splits multi-byte runes in half breaking encoding.
2026-05-24 17:40:08 -07:00
jvoisin 47e304f343 perf(integration): don't defer in a for loop
Since the Body isn't used, it can immediately be closed, instead of deferring
the operation to the end of the function.
2026-05-23 21:35:47 -07:00
gudvinr 9dfed35946 refactor(storage): consistent construction of query builders
Make sure there's only one way to create new builder
2026-05-23 21:31:44 -07:00
gudvinr 95f5f1e77d refactor(storage): use query builder as builder
As query builders declared as such embrace this to the full extent.
2026-05-23 21:31:44 -07:00
gudvinr a08f598cc8 refactor(storage): return entryPaginationBuilder from builder methods 2026-05-23 21:31:44 -07:00
jvoisin 0e1523551b perf(finder): optimize a tad findCanonicalURL
- Don't call strings.TrimSpace twice on canonicalHref
- Use doc.FindMatcher+goquery.Single instead of doc.Find+First, as is done
  everywhere else in the codebase.
2026-05-23 20:58:55 -07:00
jvoisin eff9502462 refactor(storage): use INNER JOIN where LEFT JOIN is redundant
Replace LEFT JOIN with INNER JOIN in queries where the WHERE clause or
foreign key constraints already guarantee matching rows exist:

- Icons(): filters on feeds.user_id
- UserByAPIKey(): filters on api_keys.token
- fetchFeedCounter(): filters on feeds columns when counterJoinFeeds is set
- fetchEntries(): entries have FK constraints to feeds, categories, and
  users (feed_icons/icons kept as LEFT JOIN since icons are optional)

PostgreSQL already optimizes these identically, but INNER JOIN makes the
intent explicit.
2026-05-23 20:50:18 -07:00
jvoisin ab94f42ad1 refactor(storage): Use INNER JOIN instead of LEFT JOIN in IconByFeedID
The WHERE clause on feeds columns already eliminates NULL-extended rows,
making the LEFT JOINs logically equivalent to INNER JOINs. PostgreSQL's
planner is smart enough to recognize this and produces an identical
execution plan (verified with EXPLAIN ANALYZE), but using INNER JOIN
makes the intent explicit for humans like me reading the query.
2026-05-23 20:42:26 -07:00
gudvinr f66772e911 feat(sanitizer): speed up TruncateHTML by a lot 2026-05-23 18:15:36 -07:00
gudvinr 43456daddf feat(sanitizer): TruncateHTML benchmarks 2026-05-23 18:15:36 -07:00
gudvinr 863d6039ad refactor(sanitizer): split common stripping code into iterator function 2026-05-23 18:15:36 -07:00
gudvinr fc3e548be7 refactor(sanitizer): move truncate tests to single table 2026-05-23 18:15:36 -07:00
Harold Kim 096a15ef14 feat(locale): add Korean translation 2026-05-23 18:07:11 -07:00
jvoisin 0976efd163 refactor(readingtime): get rid of the obnoxious casts dance 2026-05-19 20:26:52 -07:00
Frédéric Guillot 06e36c3e54 Revert "feat(ui): add stdlib cross-origin protection middleware"
This reverts commit deef74e75b.
2026-05-17 20:40:39 -07:00
jvoisin 39772c33f0 perf(subscription): use a slice instead of a map for well-known feed paths
findSubscriptionsFromWellKnownURLs iterates a fixed table of 9 well-known
feed paths against the discovered base URLs. The table was declared as a
map[string]string, which paid per-iteration hash overhead and gave
non-deterministic probe order across runs.

This commit replaces it with a fixed-size [...]struct{path, format string}.
Probes now run in declared order (more predictable behavior for users when
multiple well-known URLs respond, and easier to reason about in tests), and the
inner loop avoids the map iterator entirely.

Micro-benchmark replaying the double loop body against 2 base URLs ×
9 paths, sans HTTP I/O (medians of 5 runs):

    name           old ns/op    new ns/op    delta
    KnownURLs      24,194       18,150       -25%

    name           old B/op     new B/op     delta
    KnownURLs      9,688        9,072        -6.4%

    name           old allocs/op  new allocs/op  delta
    KnownURLs      110            107            -3
2026-05-17 19:16:00 -07:00
jvoisin 098270cb14 refactor(misc): various minor code simplifications
- No need to use `make(…)` construct for empty slices, as is already the case
  in the rest of internal/googlereader/handler.go
- Replace a useless condition in internal/reader/sanitizer/sanitizer.go with an
  unconditional assignment.
- Remove a useless call to url.Parse in JoinBaseURLAndPath, as url.JoinPath
  already performs the validation internally
2026-05-17 19:07:37 -07:00
jvoisin 24c65304a7 perf(date): cache timezone Locations for PST/PDT/EST/EDT fallback
parseLocalTimeDates is called once per parsed feed entry. Each call to
time.LoadLocation("America/Los_Angeles" | "America/New_York") reads and
parses the IANA tzdata file from disk or from the embedded zoneinfo.

This commit hoists the two LoadLocation calls to package-level vars so the
lookup happens once at init time and the hot path becomes a pointer load.

Benchmarked on the local-time fallback path:
  before: ~24,000 ns/op, ~15 KB/op, 26 allocs/op
  after:  ~330    ns/op, 0  B/op,   0  allocs/op
2026-05-17 19:06:47 -07:00
jvoisin 3747e686af security(metrics): use constant-time comparison for metrics endpoint credentials
The metrics Basic Auth check used != for username and password, which is
technically vulnerable to timing side-channel attacks. Since the metrics
credentials are typically short static config values, the timing difference is
very likely to be in the noise level, but oh well, it's a good practise to do
credential validation in constant time.
2026-05-17 19:05:52 -07:00
jvoisin cc1d39efe1 perf(misc): preallocate some slices 2026-05-16 20:42:33 -07:00
jvoisin bdd7f4f365 perf(database): drop two redundant indexes
entries_feed_idx(feed_id) is covered by both the unique constraint
entries_feed_id_hash_key(feed_id, hash) and the explicit index
entries_feed_id_status_hash_idx(feed_id, status, hash), which handle
all feed_id-leading lookups including FK cascade deletes.

entries_user_status_idx(user_id, status) is a prefix of five existing
three-column indexes (entries_user_status_feed_idx,
entries_user_status_changed_idx, entries_user_status_published_idx,
entries_user_status_created_idx, entries_user_status_changed_published_idx),
all of which serve every query the two-column index could.

Saves ~14 MB per million entries.
2026-05-16 19:32:43 -07:00
gudvinr 7628a214f3 fix(storage): remove possible SQL injection
As ORDER BY strings can't be included in parametrized queries, queries containing them are vulnerable to SQL injections.
2026-05-16 19:20:12 -07:00
148 changed files with 3575 additions and 1459 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 -2
View File
@@ -74,8 +74,6 @@ type UserModificationRequest struct {
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
@@ -148,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"`
@@ -174,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.
@@ -187,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"`
@@ -207,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"`
@@ -222,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"`
@@ -320,6 +332,7 @@ type Filter struct {
CategoryID int64
FeedID int64
Statuses []string
Tags []string
GloballyVisible bool
}
@@ -329,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"`
+9 -9
View File
@@ -8,21 +8,21 @@ 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/go-webauthn/webauthn v0.17.3
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.51.0
golang.org/x/image v0.40.0
golang.org/x/net v0.54.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 (
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
@@ -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.44.0 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
+18 -18
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=
@@ -19,10 +19,10 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.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
+15 -11
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))
@@ -163,15 +168,14 @@ func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request)
return
}
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithUserID(userID).
WithCategoryID(categoryID).
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
+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)
+112 -53
View File
@@ -55,9 +55,9 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithFeedID(feedID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -75,9 +75,9 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithCategoryID(categoryID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -89,8 +89,8 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -161,25 +161,25 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
tags := request.QueryStringParamList(r, "tags")
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)
builder.WithSorting(order, direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder := h.store.NewEntryQueryBuilder(userID).
WithFeedID(feedID).
WithCategoryID(categoryID).
WithStatuses(statuses...).
WithSorting(order, direction).
WithOffset(offset).
WithLimit(limit).
WithTags(tags...).
WithEnclosures()
if request.HasQueryParam(r, "globally_visible") {
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)
@@ -236,15 +246,14 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
if !h.store.HasSaveEntry(request.UserID(r)) {
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -285,10 +294,10 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
}
loggedUserID := request.UserID(r)
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -445,10 +454,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -470,9 +478,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
WithFeedID(entry.FeedID).
GetFeed()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -499,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
}
+8 -9
View File
@@ -76,14 +76,13 @@ func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithUserID(userID).
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -116,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`)
+8 -9
View File
@@ -20,14 +20,13 @@ func refreshFeeds(store *storage.Storage) {
startTime := time.Now()
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(config.Opts.BatchSize())
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := store.NewBatchBuilder().
WithBatchSize(config.Opts.BatchSize()).
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
@@ -36,7 +35,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
var jobQueue = make(chan model.Job, nbJobs)
jobQueue := make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
+8 -7
View File
@@ -33,14 +33,15 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
jobs, err := store.NewBatchBuilder().
WithBatchSize(batchSize).
WithErrorLimit(errorLimit).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(limitPerHost).
FetchJobs()
if jobs, err := batchBuilder.FetchJobs(); err != nil {
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
+37 -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
}
@@ -1510,4 +1519,30 @@ var migrations = [...]func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
// entries_feed_idx is redundant: the unique constraint
// entries_feed_id_hash_key(feed_id, hash) and the explicit
// entries_feed_id_status_hash_idx(feed_id, status, hash) both
// cover feed_id-leading lookups, including FK cascade deletes.
//
// entries_user_status_idx is redundant: five three-column indexes
// share the same (user_id, status) prefix and serve every query
// that the two-column index could.
_, err = tx.Exec(`
DROP INDEX IF EXISTS entries_feed_idx;
DROP INDEX IF EXISTS entries_user_status_idx;
`)
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
},
}
+19 -21
View File
@@ -238,8 +238,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithLimit(50)
builder := h.store.NewEntryQueryBuilder(userID).
WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
@@ -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",
@@ -292,8 +292,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
return
}
builder = h.store.NewEntryQueryBuilder(userID)
result.Total, err = builder.CountEntries()
result.Total, err = h.store.NewEntryQueryBuilder(userID).
CountEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -342,9 +342,9 @@ func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
rawEntryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStatuses(model.EntryStatusUnread).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -375,10 +375,9 @@ func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true)
entryIDs, err := builder.GetEntryIDs()
entryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStarred(true).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -410,10 +409,9 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
+59 -51
View File
@@ -236,20 +236,19 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
slog.Any("tags", tags),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(itemIDs...).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
}
n := 0
readEntryIDs := make([]int64, 0)
unreadEntryIDs := make([]int64, 0)
starredEntryIDs := make([]int64, 0)
unstarredEntryIDs := make([]int64, 0)
var readEntryIDs []int64
var unreadEntryIDs []int64
var starredEntryIDs []int64
var unstarredEntryIDs []int64
for _, entry := range entries {
if read, exists := tags[ReadStream]; exists {
if read && entry.Status == model.EntryStatusUnread {
@@ -343,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
@@ -649,12 +649,11 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
slog.Any("item_ids", itemIDs),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEnclosures().
WithEntryIDs(itemIDs...).
WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -1012,11 +1011,15 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
slog.String("user_agent", r.UserAgent()),
)
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
builder.WithStatus(model.EntryStatusUnread)
builder = builder.WithStatuses(model.EntryStatusUnread)
default:
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
@@ -1027,14 +1030,12 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
}
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.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)
@@ -1046,36 +1047,42 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithStarred(true)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStarred(true).
WithLimit(rm.Count).
WithOffset(rm.Offset).
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)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithStatus(model.EntryStatusRead)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStatuses(model.EntryStatusRead).
WithLimit(rm.Count).
WithOffset(rm.Offset).
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)
@@ -1083,6 +1090,7 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
@@ -1091,7 +1099,7 @@ func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestMod
if err != nil {
return nil, 0, err
}
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
itemRefs := make([]itemRef, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
@@ -1115,32 +1123,32 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
return
}
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithFeedID(feedID)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithFeedID(feedID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
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))
}
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder = builder.WithoutStatus(model.EntryStatusRead)
}
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
+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()
}
+20 -18
View File
@@ -6,8 +6,10 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"net/http"
"strings"
@@ -23,14 +25,14 @@ type Builder struct {
w http.ResponseWriter
r *http.Request
statusCode int
headers map[string]string
headers http.Header
enableCompression bool
body any
}
// NewBuilder creates a new response builder.
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
}
// WithStatus uses the given status code to build the response.
@@ -41,7 +43,7 @@ func (b *Builder) WithStatus(statusCode int) *Builder {
// WithHeader adds the given HTTP header to the response.
func (b *Builder) WithHeader(key, value string) *Builder {
b.headers[key] = value
b.headers.Set(key, value)
return b
}
@@ -65,13 +67,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = formatContentDisposition("attachment", filename)
b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
return b
}
// WithInline suggests an inline filename for the current response.
func (b *Builder) WithInline(filename string) *Builder {
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
return b
}
@@ -84,9 +86,11 @@ func (b *Builder) WithoutCompression() *Builder {
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
etag = normalizeETag(etag)
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public, immutable"
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
b.headers.Set("ETag", etag)
// 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) {
b.statusCode = http.StatusNotModified
@@ -120,24 +124,22 @@ func (b *Builder) Write() {
}
func (b *Builder) writeHeaders() {
b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer"
b.headers.Set("X-Content-Type-Options", "nosniff")
b.headers.Set("X-Frame-Options", "DENY")
b.headers.Set("Referrer-Policy", "no-referrer")
for key, value := range b.headers {
b.w.Header().Set(key, value)
}
maps.Copy(b.w.Header(), b.headers)
b.w.WriteHeader(b.statusCode)
}
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
b.headers["Vary"] = "Accept-Encoding"
b.headers.Set("Vary", "Accept-Encoding")
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.headers.Set("Content-Encoding", "br")
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
@@ -145,7 +147,7 @@ func (b *Builder) compress(data []byte) {
brotliWriter.Close()
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.headers.Set("Content-Encoding", "gzip")
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
@@ -153,7 +155,7 @@ func (b *Builder) compress(data []byte) {
gzipWriter.Close()
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.headers.Set("Content-Encoding", "deflate")
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
+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()
}
+3
View File
@@ -224,6 +224,9 @@ func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
"file:///etc/passwd",
"mailto:victim@example.org",
"//evil.example.org/path",
`/\evil.example.org/path`,
`\evil.example.org\path`,
`/foo\bar`,
"ftp://example.org/file",
"",
}
+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()
}
+6 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -54,7 +55,11 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
return false
}
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
// Both checks have to be run to avoid leaking informations
// about the username and the password.
usernameCorrect := crypto.ConstantTimeCmp(username, config.Opts.MetricsUsername())
passwordCorrect := crypto.ConstantTimeCmp(password, config.Opts.MetricsPassword())
if !usernameCorrect || !passwordCorrect {
slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
+1 -1
View File
@@ -72,7 +72,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+1 -1
View File
@@ -84,7 +84,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+1 -1
View File
@@ -58,7 +58,7 @@ func (c *Client) attachTags(entryID string) error {
return nil
}
tagItems := make([]tagItem, 0)
tagItems := make([]tagItem, 0, strings.Count(c.tags, ",")+1)
for tag := range strings.SplitSeq(c.tags, ",") {
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
+1 -1
View File
@@ -88,7 +88,7 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+1
View File
@@ -111,6 +111,7 @@ func TestTranslationFilePluralForms(t *testing.T) {
"id_ID": 1,
"it_IT": 2,
"ja_JP": 1,
"ko_KR": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"pl_PL": 3,
+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)
}
+1
View File
@@ -17,6 +17,7 @@ var AvailableLanguages = map[string]string{
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
"ja_JP": "日本語",
"ko_KR": "한국어",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"pl_PL": "Polski",
+1 -1
View File
@@ -36,7 +36,7 @@ func getPluralForm(lang string, n int) int {
return 1
}
return 0
case "id_ID", "ja_JP":
case "id_ID", "ja_JP", "ko_KR":
return 0
case "pl_PL":
switch {
+8
View File
@@ -66,6 +66,14 @@ func TestPluralRules(t *testing.T) {
5: 0,
100: 0,
},
// Korean (ko_KR) - always form 0
"ko_KR": {
0: 0,
1: 0,
2: 0,
5: 0,
100: 0,
},
// Polish (pl_PL) - 3 forms
"pl_PL": {
1: 0, // n == 1
+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:",
+620
View File
@@ -0,0 +1,620 @@
{
"action.cancel": "취소",
"action.download": "다운로드",
"action.edit": "편집",
"action.home_screen": "홈 화면에 추가",
"action.import": "가져오기",
"action.login": "로그인",
"action.or": "또는",
"action.remove": "삭제",
"action.remove_feed": "이 피드 삭제",
"action.save": "저장",
"action.subscribe": "피드 구독",
"action.update": "업데이트",
"alert.account_linked": "외부 계정과 연동되었습니다!",
"alert.account_unlinked": "외부 계정과의 연동이 해제되었습니다!",
"alert.background_feed_refresh": "모든 피드를 백그라운드에서 새로 고치는 중입니다. 이 작업 중에도 Miniflux를 계속 사용할 수 있습니다.",
"alert.feed_error": "이 피드에 문제가 있습니다.",
"alert.no_starred": "현재 즐겨찾기 표시된 게시물이 없습니다.",
"alert.no_category": "카테고리가 없습니다.",
"alert.no_category_entry": "이 카테고리에는 게시물이 없습니다.",
"alert.no_feed": "구독 중인 피드가 없습니다.",
"alert.no_feed_entry": "이 피드에는 게시물이 없습니다.",
"alert.no_feed_in_category": "이 카테고리에는 구독 중인 피드가 없습니다.",
"alert.no_history": "현재 기록이 없습니다.",
"alert.no_search_result": "검색 결과가 없습니다.",
"alert.no_shared_entry": "공유된 게시물이 없습니다.",
"alert.no_tag_entry": "이 태그와 일치하는 게시물이 없습니다.",
"alert.no_unread_entry": "읽지 않은 게시물이 없습니다.",
"alert.no_user": "당신이 유일한 사용자입니다.",
"alert.prefs_saved": "설정이 정상적으로 저장되었습니다!",
"alert.too_many_feeds_refresh": [
"피드 새로고침 요청이 너무 많습니다. %d분 후 다시 시도해 주세요."
],
"confirm.loading": "실행 중…",
"confirm.no": "아니요",
"confirm.question": "진행하시겠습니까?",
"confirm.question.refresh": "강제로 새로 고치시겠습니까?",
"confirm.yes": "예",
"enclosure_media_controls.seek": "탐색:",
"enclosure_media_controls.seek.title": "%s초 이동",
"enclosure_media_controls.speed": "속도:",
"enclosure_media_controls.speed.faster": "빠르게",
"enclosure_media_controls.speed.faster.title": "%sx 빠르게",
"enclosure_media_controls.speed.reset": "초기화",
"enclosure_media_controls.speed.reset.title": "속도를 1x로 초기화",
"enclosure_media_controls.speed.slower": "느리게",
"enclosure_media_controls.speed.slower.title": "%sx 느리게",
"entry.starred.toast.off": "즐겨찾기를 해제했습니다",
"entry.starred.toast.on": "즐겨찾기로 설정했습니다",
"entry.starred.toggle.off": "즐겨찾기 해제",
"entry.starred.toggle.on": "즐겨찾기 설정",
"entry.comments.label": "댓글",
"entry.comments.title": "댓글 보기",
"entry.estimated_reading_time": [
"%d분 소요"
],
"entry.external_link.label": "외부 링크",
"entry.save.completed": "완료!",
"entry.save.label": "저장",
"entry.save.title": "이 게시물 저장",
"entry.save.toast.completed": "게시물이 저장되었습니다",
"entry.scraper.completed": "완료!",
"entry.scraper.label": "다운로드",
"entry.scraper.title": "원본 내용 가져오기",
"entry.share.label": "공유",
"entry.share.title": "이 게시물 공유",
"entry.shared_entry.label": "공유하기",
"entry.shared_entry.title": "공개 링크 열기",
"entry.state.loading": "불러오는 중…",
"entry.state.saving": "저장 중…",
"entry.status.mark_as_read": "읽음으로 표시",
"entry.status.mark_as_unread": "읽지 않음으로 표시",
"entry.status.title": "게시물 상태 변경",
"entry.status.toast.read": "읽음으로 표시했습니다",
"entry.status.toast.unread": "읽지 않음으로 표시했습니다",
"entry.tags.label": "태그:",
"entry.tags.more_tags_label": [
"태그 %d개"
],
"entry.unshare.label": "공유 해제",
"error.api_key_already_exists": "이 API 키는 이미 존재합니다.",
"error.bad_credentials": "사용자명 또는 비밀번호가 잘못되었습니다.",
"error.category_already_exists": "이 카테고리는 이미 존재합니다.",
"error.category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.database_error": "데이터베이스 오류: %v.",
"error.different_passwords": "비밀번호가 일치하지 않습니다.",
"error.duplicate_fever_username": "같은 Fever 사용자명이 이미 사용 중입니다!",
"error.duplicate_googlereader_username": "같은 Google Reader 사용자명이 이미 사용 중입니다!",
"error.duplicate_linked_account": "다른 사용자가 이미 이 서비스의 동일한 사용자와 연동되어 있습니다.",
"error.duplicated_feed": "이 피드는 이미 존재합니다.",
"error.empty_file": "이 파일은 비어 있습니다.",
"error.entries_per_page_invalid": "페이지당 게시물 수가 유효하지 않습니다.",
"error.feed_already_exists": "이 피드는 이미 존재합니다.",
"error.feed_category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_format_not_detected": "피드 형식을 감지할 수 없습니다: %v.",
"error.feed_invalid_blocklist_rule": "차단 목록 규칙이 유효하지 않습니다.",
"error.feed_invalid_keeplist_rule": "허용 목록 규칙이 유효하지 않습니다.",
"error.feed_mandatory_fields": "URL과 카테고리가 필요합니다.",
"error.feed_not_found": "이 피드는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_title_not_empty": "피드 제목은 비워 둘 수 없습니다.",
"error.feed_url_not_empty": "피드 URL은 비워 둘 수 없습니다.",
"error.fields_mandatory": "모든 항목을 입력해주세요.",
"error.http_bad_gateway": "잘못된 게이트웨이 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 나중에 다시 시도해 주세요.",
"error.http_body_read": "HTTP 본문을 읽을 수 없습니다: %v.",
"error.http_client_error": "HTTP 클라이언트 오류: %v.",
"error.http_cloudflare_challenge": "이 웹사이트는 Cloudflare 봇 챌린지(CAPTCHA 또는 JavaScript 검증)로 보호되어 있습니다. Miniflux는 이 챌린지를 자동으로 해결할 수 없습니다.",
"error.http_empty_response": "HTTP 응답이 비어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_empty_response_body": "HTTP 응답 본문이 비어 있습니다.",
"error.http_forbidden": "이 웹사이트에 대한 접근이 금지되어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_gateway_timeout": "게이트웨이 시간 초과로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_internal_server_error": "서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_not_authorized": "이 웹사이트에 대한 접근이 허용되지 않았습니다. 사용자명 또는 비밀번호가 올바르지 않을 수 있습니다.",
"error.http_resource_not_found": "요청한 리소스를 찾을 수 없습니다. URL을 확인해 주세요.",
"error.http_response_too_large": "HTTP 응답이 너무 큽니다. 전역 설정에서 HTTP 응답 크기 제한을 늘릴 수 있습니다. (서버 재시작 필요)",
"error.http_service_unavailable": "내부 서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. 문제는 Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_too_many_requests": "Miniflux가 이 웹사이트에 너무 많은 요청을 보냈습니다. 잠시 기다리거나 애플리케이션 설정을 변경해 주세요.",
"error.http_unexpected_status_code": "예상치 못한 HTTP 상태 코드(%d)로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.invalid_categories_sorting_order": "카테고리 표시 순서가 유효하지 않습니다.",
"error.invalid_default_home_page": "기본 시작 페이지가 유효하지 않습니다",
"error.invalid_display_mode": "웹 앱 표시 모드가 유효하지 않습니다.",
"error.invalid_entry_direction": "게시물 표시 방향이 유효하지 않습니다.",
"error.invalid_entry_order": "게시물 표시 순서가 유효하지 않습니다.",
"error.invalid_feed_proxy_url": "프록시 URL이 유효하지 않습니다.",
"error.invalid_feed_url": "피드 URL이 유효하지 않습니다.",
"error.invalid_gesture_nav": "제스처 내비게이션이 유효하지 않습니다.",
"error.invalid_language": "언어가 유효하지 않습니다.",
"error.invalid_site_url": "사이트 URL이 유효하지 않습니다.",
"error.invalid_theme": "테마가 유효하지 않습니다.",
"error.invalid_timezone": "시간대가 유효하지 않습니다.",
"error.network_operation": "네트워크 오류로 인해 Miniflux가 이 웹사이트에 도달할 수 없습니다: %v.",
"error.network_timeout": "이 웹사이트의 응답이 너무 느려 시간 초과되었습니다: %v",
"error.password_min_length": "비밀번호는 6자 이상이어야 합니다.",
"error.proxy_url_not_empty": "프록시 URL은 비워 둘 수 없습니다.",
"error.settings_block_rule_fieldname_invalid": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_block_rule_invalid_regex": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_block_rule_regex_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_block_rule_separator_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_invalid_domain_list": "도메인 목록이 유효하지 않습니다. 도메인은 공백으로 구분해 지정해 주세요.",
"error.settings_keep_rule_fieldname_invalid": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_keep_rule_invalid_regex": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_keep_rule_regex_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_keep_rule_separator_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_mandatory_fields": "사용자명, 테마, 언어, 시간대가 모두 필요합니다.",
"error.settings_media_playback_rate_range": "재생 속도가 범위를 벗어났습니다",
"error.settings_reading_speed_is_positive": "읽기 속도는 양의 정수여야 합니다.",
"error.site_url_not_empty": "사이트 URL은 비워 둘 수 없습니다.",
"error.subscription_not_found": "피드를 찾을 수 없습니다.",
"error.title_required": "제목이 필요합니다.",
"error.tls_error": "TLS 오류: %q. 필요한 경우 피드 설정에서 TLS 검증을 비활성화할 수 있습니다.",
"error.unable_to_create_api_key": "이 API 키를 만들 수 없습니다.",
"error.unable_to_create_category": "이 카테고리를 만들 수 없습니다.",
"error.unable_to_create_user": "이 사용자를 만들 수 없습니다.",
"error.unable_to_detect_rssbridge": "RSS-Bridge를 사용해 피드를 감지할 수 없습니다: %v.",
"error.unable_to_parse_feed": "이 피드를 파싱할 수 없습니다: %v.",
"error.unable_to_update_category": "이 카테고리를 업데이트할 수 없습니다.",
"error.unable_to_update_feed": "이 피드를 업데이트할 수 없습니다.",
"error.unable_to_update_user": "이 사용자를 업데이트할 수 없습니다.",
"error.unlink_account_without_password": "비밀번호를 설정하지 않으면 다시 로그인할 수 없습니다.",
"error.user_already_exists": "이 사용자는 이미 존재합니다.",
"error.user_mandatory_fields": "사용자명이 필요합니다.",
"error.linktaco_missing_required_fields": "LinkTaco API 토큰과 조직 슬러그가 필요합니다",
"form.api_key.label.description": "API키 설명",
"form.category.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.category.label.title": "제목",
"form.feed.fieldset.general": "일반",
"form.feed.fieldset.integration": "서드파티 서비스",
"form.feed.fieldset.network_settings": "네트워크 설정",
"form.feed.fieldset.rules": "규칙",
"form.feed.label.allow_self_signed_certificates": "자체 서명 인증서 또는 유효하지 않은 인증서 허용",
"form.feed.label.apprise_service_urls": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.feed.label.block_filter_entry_rules": "게시물 차단 규칙",
"form.feed.label.blocklist_rules": "정규식 기반 차단 필터",
"form.feed.label.category": "카테고리",
"form.feed.label.cookie": "Cookie 설정",
"form.feed.label.crawler": "게시물 본문도 함께 다운로드",
"form.feed.label.ignore_entry_updates": "업데이트된 게시물 무시",
"form.feed.label.description": "설명",
"form.feed.label.disable_http2": "핑거프린팅 회피를 위해 HTTP/2 비활성화",
"form.feed.label.disabled": "이 피드를 업데이트하지 않음",
"form.feed.label.feed_password": "피드 비밀번호",
"form.feed.label.feed_url": "피드 URL",
"form.feed.label.feed_username": "피드 사용자명",
"form.feed.label.fetch_via_proxy": "애플리케이션 수준에서 설정된 프록시 사용",
"form.feed.label.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.feed.label.ignore_http_cache": "HTTP 캐시 무시",
"form.feed.label.keep_filter_entry_rules": "게시물 허용 규칙",
"form.feed.label.keeplist_rules": "정규식 기반 보존 필터",
"form.feed.label.no_media_player": "미디어 기능 비활성화 (오디오/비디오)",
"form.feed.label.ntfy_activate": "게시물을 ntfy로 전송",
"form.feed.label.ntfy_default_priority": "ntfy 기본 우선순위",
"form.feed.label.ntfy_high_priority": "ntfy 높은 우선순위",
"form.feed.label.ntfy_low_priority": "ntfy 낮은 우선순위",
"form.feed.label.ntfy_max_priority": "ntfy 최대 우선순위",
"form.feed.label.ntfy_min_priority": "ntfy 최소 우선순위",
"form.feed.label.ntfy_priority": "ntfy 우선순위",
"form.feed.label.ntfy_topic": "ntfy 토픽(선택 사항)",
"form.feed.label.proxy_url": "프록시 URL",
"form.feed.label.pushover_activate": "게시물을 pushover.net으로 전송",
"form.feed.label.pushover_default_priority": "Pushover 기본 우선순위",
"form.feed.label.pushover_high_priority": "Pushover 높은 우선순위",
"form.feed.label.pushover_low_priority": "Pushover 낮은 우선순위",
"form.feed.label.pushover_max_priority": "Pushover 최대 우선순위",
"form.feed.label.pushover_min_priority": "Pushover 최소 우선순위",
"form.feed.label.pushover_priority": "Pushover 메시지 우선순위",
"form.feed.label.rewrite_rules": "본문 재작성 규칙",
"form.feed.label.scraper_rules": "본문 추출 규칙",
"form.feed.label.site_url": "사이트 URL",
"form.feed.label.title": "제목",
"form.feed.label.urlrewrite_rules": "URL 재작성 규칙",
"form.feed.label.user_agent": "기본 User Agent 덮어쓰기",
"form.feed.label.webhook_url": "Webhook URL 덮어쓰기",
"form.import.label.file": "OPML 파일",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "게시물을 archive.org로 푸시",
"form.integration.apprise_activate": "게시물을 Apprise로 전송",
"form.integration.apprise_services_url": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "게시물을 Betula에 저장",
"form.integration.betula_token": "Betula 토큰",
"form.integration.betula_url": "Betula 서버 URL",
"form.integration.cubox_activate": "게시물을 Cubox에 저장",
"form.integration.cubox_api_link": "Cubox API 링크",
"form.integration.discord_activate": "게시물을 Discord로 전송",
"form.integration.discord_webhook_link": "Discord Webhook 링크",
"form.integration.espial_activate": "Espial에 게시물 저장",
"form.integration.espial_api_key": "Espial API 키",
"form.integration.espial_endpoint": "Espial API 엔드포인트",
"form.integration.espial_tags": "Espial 태그",
"form.integration.fever_activate": "Fever API 활성화",
"form.integration.fever_endpoint": "Fever API 엔드포인트:",
"form.integration.fever_password": "Fever 비밀번호",
"form.integration.fever_username": "Fever 사용자명",
"form.integration.googlereader_activate": "Google Reader API 활성화",
"form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
"form.integration.googlereader_password": "Google Reader 비밀번호",
"form.integration.googlereader_username": "Google Reader 사용자명",
"form.integration.instapaper_activate": "Instapaper에 게시물 저장",
"form.integration.instapaper_password": "Instapaper 비밀번호",
"form.integration.instapaper_username": "Instapaper 사용자명",
"form.integration.karakeep_activate": "Karakeep에 게시물 저장",
"form.integration.karakeep_api_key": "Karakeep API 키",
"form.integration.karakeep_url": "Karakeep API 엔드포인트",
"form.integration.karakeep_tags": "Karakeep 태그",
"form.integration.linkace_activate": "게시물을 LinkAce에 저장",
"form.integration.linkace_api_key": "LinkAce API 키",
"form.integration.linkace_check_disabled": "링크 확인 비활성화",
"form.integration.linkace_endpoint": "LinkAce API 엔드포인트",
"form.integration.linkace_is_private": "링크를 비공개로 설정",
"form.integration.linkace_tags": "LinkAce 태그",
"form.integration.linkding_activate": "Linkding에 게시물 저장",
"form.integration.linkding_api_key": "Linkding API 키",
"form.integration.linkding_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.linkding_endpoint": "Linkding API 엔드포인트",
"form.integration.linkding_tags": "Linkding 태그",
"form.integration.linktaco_activate": "LinkTaco에 게시물 저장",
"form.integration.linktaco_api_token": "LinkTaco API 토큰",
"form.integration.linktaco_api_token_hint": "개인용 액세스 토큰 받기",
"form.integration.linktaco_org_slug": "조직 슬러그",
"form.integration.linktaco_tags": "태그(최대 10개, 쉼표로 구분)",
"form.integration.linktaco_tags_hint": "최대 10개의 태그, 쉼표로 구분",
"form.integration.linktaco_visibility": "공개 설정",
"form.integration.linktaco_visibility_public": "공개",
"form.integration.linktaco_visibility_private": "비공개",
"form.integration.linktaco_visibility_hint": "비공개 설정에는 유료 LinkTaco 계정이 필요합니다",
"form.integration.linkwarden_activate": "Linkwarden에 게시물 저장",
"form.integration.linkwarden_api_key": "Linkwarden API 키",
"form.integration.linkwarden_endpoint": "Linkwarden 기본 URL",
"form.integration.linkwarden_collection_id": "Linkwarden 컬렉션 ID",
"form.integration.matrix_bot_activate": "새 게시물을 Matrix로 전달",
"form.integration.matrix_bot_chat_id": "Matrix 룸 ID",
"form.integration.matrix_bot_password": "Matrix 사용자 비밀번호",
"form.integration.matrix_bot_url": "Matrix 서버 URL",
"form.integration.matrix_bot_user": "Matrix 사용자명",
"form.integration.notion_activate": "게시물을 Notion에 저장",
"form.integration.notion_page_id": "Notion 페이지 ID",
"form.integration.notion_token": "Notion 시크릿 토큰",
"form.integration.ntfy_activate": "게시물을 ntfy로 전송",
"form.integration.ntfy_api_token": "ntfy API 토큰(선택 사항)",
"form.integration.ntfy_icon_url": "ntfy 아이콘 URL(선택 사항)",
"form.integration.ntfy_internal_links": "클릭 시 내부 링크 사용(선택 사항)",
"form.integration.ntfy_password": "ntfy 비밀번호(선택 사항)",
"form.integration.ntfy_topic": "ntfy 토픽(피드에 설정되어 있지 않으면 기본값)",
"form.integration.ntfy_url": "ntfy URL(선택 사항, 기본값 ntfy.sh)",
"form.integration.ntfy_username": "ntfy 사용자명(선택 사항)",
"form.integration.nunux_keeper_activate": "Nunux Keeper에 게시물 저장",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 키",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 엔드포인트",
"form.integration.omnivore_activate": "Omnivore에 게시물 저장",
"form.integration.omnivore_api_key": "Omnivore API 키",
"form.integration.omnivore_url": "Omnivore API 엔드포인트",
"form.integration.pinboard_activate": "Pinboard에 게시물 저장",
"form.integration.pinboard_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.pinboard_tags": "Pinboard 태그",
"form.integration.pinboard_token": "Pinboard API 토큰",
"form.integration.pushover_activate": "게시물을 Pushover로 전송",
"form.integration.pushover_device": "Pushover 기기(선택 사항)",
"form.integration.pushover_prefix": "Pushover URL 접두사(선택 사항)",
"form.integration.pushover_token": "Pushover 앱 API 토큰",
"form.integration.pushover_user": "Pushover 사용자 키",
"form.integration.raindrop_activate": "게시물을 Raindrop에 저장",
"form.integration.raindrop_collection_id": "컬렉션 ID",
"form.integration.raindrop_tags": "태그(쉼표로 구분)",
"form.integration.raindrop_token": "(테스트) 토큰",
"form.integration.readeck_activate": "Readeck에 게시물 저장",
"form.integration.readeck_api_key": "Readeck API 키",
"form.integration.readeck_endpoint": "Readeck API 엔드포인트",
"form.integration.readeck_labels": "Readeck 라벨",
"form.integration.readeck_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.readeck_push_activate": "새 게시물을 자동으로 Readeck에 전송",
"form.integration.readwise_activate": "Readwise Reader에 게시물 저장",
"form.integration.readwise_api_key": "Readwise Reader 액세스 토큰",
"form.integration.readwise_api_key_link": "Readwise 액세스 토큰 받기",
"form.integration.rssbridge_activate": "구독 추가 시 RSS-Bridge 확인",
"form.integration.rssbridge_token": "RSS-Bridge 인증 토큰",
"form.integration.rssbridge_url": "RSS-Bridge 서버 URL",
"form.integration.shaarli_activate": "게시물을 Shaarli에 저장",
"form.integration.shaarli_api_secret": "Shaarli API 시크릿",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "게시물을 Shiori에 저장",
"form.integration.shiori_endpoint": "Shiori API 엔드포인트",
"form.integration.shiori_password": "Shiori 비밀번호",
"form.integration.shiori_username": "Shiori 사용자명",
"form.integration.slack_activate": "게시물을 Slack으로 전송",
"form.integration.slack_webhook_link": "Slack Webhook 링크",
"form.integration.telegram_bot_activate": "새 게시물을 Telegram 채팅으로 푸시",
"form.integration.telegram_bot_disable_buttons": "버튼 비활성화",
"form.integration.telegram_bot_disable_notification": "알림 비활성화",
"form.integration.telegram_bot_disable_web_page_preview": "웹 페이지 미리보기 비활성화",
"form.integration.telegram_bot_token": "봇 토큰",
"form.integration.telegram_chat_id": "채팅 ID",
"form.integration.telegram_topic_id": "토픽 ID",
"form.integration.wallabag_activate": "Wallabag에 게시물 저장",
"form.integration.wallabag_client_id": "Wallabag 클라이언트 ID",
"form.integration.wallabag_client_secret": "Wallabag 클라이언트 시크릿",
"form.integration.wallabag_endpoint": "Wallabag 기본 URL",
"form.integration.wallabag_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.wallabag_password": "Wallabag 비밀번호",
"form.integration.wallabag_username": "Wallabag 사용자명",
"form.integration.wallabag_tags": "Wallabag 태그",
"form.integration.webhook_activate": "Webhook 활성화",
"form.integration.webhook_secret": "Webhook 시크릿",
"form.integration.webhook_url": "기본 Webhook URL",
"form.prefs.fieldset.application_settings": "애플리케이션 설정",
"form.prefs.fieldset.authentication_settings": "비밀번호 인증",
"form.prefs.fieldset.google_authentication": "Google 인증",
"form.prefs.fieldset.oidc_authentication": "%s 인증",
"form.prefs.fieldset.global_feed_settings": "전역 피드 설정",
"form.prefs.fieldset.reader_settings": "리더 설정",
"form.prefs.help.external_font_hosts": "허용할 외부 폰트 호스트를 공백으로 구분해 지정합니다. 예: \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "외부 링크를 열어 게시물 읽기",
"form.prefs.label.categories_sorting_order": "카테고리 표시 순서",
"form.prefs.label.cjk_reading_speed": "한국어, 일본어, 중국어 읽기 속도 (문자/분)",
"form.prefs.label.custom_css": "사용자 지정 CSS",
"form.prefs.label.custom_js": "사용자 지정 JavaScript",
"form.prefs.label.default_home_page": "기본 시작 페이지",
"form.prefs.label.default_reading_speed": "다른 언어의 읽기 속도(단어/분)",
"form.prefs.label.display_mode": "프로그레시브 웹 앱(PWA) 표시 모드",
"form.prefs.label.entries_per_page": "페이지당 게시물 수",
"form.prefs.label.entry_order": "게시물 표시 순서 기준",
"form.prefs.label.entry_sorting": "게시물 표시 순서",
"form.prefs.label.entry_swipe": "터치스크린에서 스와이프 입력 활성화",
"form.prefs.label.external_font_hosts": "외부 폰트 호스트",
"form.prefs.label.gesture_nav": "게시물 간 이동 제스처",
"form.prefs.label.keyboard_shortcuts": "키보드 단축키 활성화",
"form.prefs.label.language": "언어",
"form.prefs.label.mark_read_manually": "수동으로 읽음 처리",
"form.prefs.label.mark_read_on_media_completion": "오디오/비디오 재생이 90%%에 도달하면 읽음 처리",
"form.prefs.label.mark_read_on_view": "표시할 때 게시물을 자동으로 읽음으로 표시",
"form.prefs.label.mark_read_on_view_or_media_completion": "표시할 때 읽음 처리. 오디오/비디오는 90%% 재생 시 읽음 처리",
"form.prefs.label.media_playback_rate": "오디오/비디오 재생 속도",
"form.prefs.label.open_external_links_in_new_tab": "외부 링크를 새 탭에서 열기(링크에 target=\"_blank\" 추가)",
"form.prefs.label.show_reading_time": "게시물 예상 읽기 시간 표시",
"form.prefs.label.theme": "테마",
"form.prefs.label.timezone": "시간대",
"form.prefs.select.alphabetical": "알파벳순",
"form.prefs.select.browser": "브라우저형",
"form.prefs.select.created_time": "게시물 가져온 시각",
"form.prefs.select.fullscreen": "전체 화면",
"form.prefs.select.minimal_ui": "미니멀 UI",
"form.prefs.select.none": "없음",
"form.prefs.select.older_first": "오래된 게시물 먼저",
"form.prefs.select.publish_time": "게시물 공개 시각",
"form.prefs.select.recent_first": "새 게시물 먼저",
"form.prefs.select.standalone": "독립형",
"form.prefs.select.swipe": "스와이프",
"form.prefs.select.tap": "더블 탭",
"form.prefs.select.unread_count": "읽지 않은 항목 수",
"form.submit.loading": "불러오는 중…",
"form.submit.saving": "저장 중…",
"form.user.label.admin": "관리자",
"form.user.label.confirmation": "비밀번호 확인",
"form.user.label.password": "비밀번호",
"form.user.label.username": "사용자명",
"menu.about": "소프트웨어 정보",
"menu.add_feed": "피드 구독",
"menu.add_user": "사용자 추가",
"menu.api_keys": "API 키",
"menu.categories": "카테고리",
"menu.create_api_key": "새 API 키 만들기",
"menu.create_category": "카테고리 만들기",
"menu.edit_category": "편집",
"menu.edit_feed": "편집",
"menu.export": "내보내기",
"menu.feed_entries": "게시물 목록",
"menu.feeds": "피드 목록",
"menu.flush_history": "기록 지우기",
"menu.history": "기록",
"menu.home_page": "홈페이지",
"menu.import": "가져오기",
"menu.integrations": "연동",
"menu.logout": "로그아웃",
"menu.mark_all_as_read": "모두 읽음으로 표시",
"menu.mark_page_as_read": "이 페이지를 읽음으로 표시",
"menu.preferences": "설정 정보",
"menu.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"menu.refresh_feed": "새로고침",
"menu.search": "검색",
"menu.sessions": "세션",
"menu.settings": "설정",
"menu.shared_entries": "공유 게시물",
"menu.show_all_entries": "모든 게시물 표시",
"menu.show_only_starred_entries": "즐겨찾기만 표시",
"menu.show_only_unread_entries": "읽지 않은 게시물만 표시",
"menu.starred": "즐겨찾기",
"menu.title": "메뉴",
"menu.unread": "읽지 않음",
"menu.users": "사용자 목록",
"page.about.authors_label": "작성자:",
"page.about.authors_value": "Frédéric Guillot 및 기여자",
"page.about.build_date": "빌드 일시:",
"page.about.credits": "저작권 표시",
"page.about.db_usage": "데이터베이스 크기:",
"page.about.git_commit": "Git 커밋:",
"page.about.global_config_options": "전역 설정 옵션",
"page.about.go_version": "Go 버전:",
"page.about.license": "라이선스:",
"page.about.postgres_version": "Postgres 버전:",
"page.about.title": "소프트웨어 정보",
"page.about.version": "버전:",
"page.add_feed.choose_feed": "피드 선택",
"page.add_feed.label.url": "피드 URL",
"page.add_feed.legend.advanced_options": "고급 설정",
"page.add_feed.no_category": "카테고리가 없습니다. 카테고리가 최소 1개 필요합니다.",
"page.add_feed.submit": "피드 탐색 및 추가",
"page.add_feed.title": "새 피드",
"page.api_keys.never_used": "사용된 적 없음",
"page.api_keys.table.actions": "액션",
"page.api_keys.table.created_at": "생성일",
"page.api_keys.table.description": "설명",
"page.api_keys.table.last_used_at": "마지막 사용",
"page.api_keys.table.token": "토큰",
"page.api_keys.title": "API 키",
"page.categories.entries": "게시물 목록",
"page.categories.feed_count": [
"피드가 %d개 있습니다."
],
"page.categories.feeds": "피드 목록",
"page.categories.no_feed": "피드가 없습니다.",
"page.categories.title": "카테고리",
"page.categories_count": [
"카테고리 %d개"
],
"page.category_label": "카테고리: %s",
"page.edit_category.title": "카테고리 편집: %s",
"page.edit_feed.etag_header": "ETag 헤더:",
"page.edit_feed.last_check": "마지막 확인:",
"page.edit_feed.last_modified_header": "Last-Modified 헤더:",
"page.edit_feed.last_parsing_error": "최근 파싱 오류",
"page.edit_feed.no_header": "없음",
"page.edit_feed.title": "피드 편집: %s",
"page.edit_user.title": "사용자 편집: %s",
"page.entry.attachments": "첨부 파일",
"page.feeds.error_count": [
"오류 %d개"
],
"page.feeds.last_check": "마지막 확인:",
"page.feeds.next_check": "다음 확인:",
"page.feeds.read_counter": "읽은 게시물 수",
"page.feeds.title": "피드 목록",
"page.footer.elevator": "페이지 맨 위로 올라가기",
"page.history.title": "기록",
"page.import.title": "가져오기",
"page.integration.bookmarklet": "북마크릿",
"page.integration.bookmarklet.help": "이 특별한 링크를 사용하면 브라우저에서 직접 웹사이트의 피드를 구독할 수 있습니다.",
"page.integration.bookmarklet.instructions": "이 링크를 브라우저 북마크로 드래그하세요.",
"page.integration.bookmarklet.name": "Miniflux에 추가",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 엔드포인트",
"page.integration.miniflux_api_password": "비밀번호",
"page.integration.miniflux_api_password_value": "계정 비밀번호",
"page.integration.miniflux_api_username": "사용자명",
"page.integrations.title": "연동",
"page.keyboard_shortcuts.close_modal": "모달 대화상자 닫기",
"page.keyboard_shortcuts.download_content": "원본 내용 다운로드",
"page.keyboard_shortcuts.go_to_bottom_item": "가장 아래 게시물로 이동",
"page.keyboard_shortcuts.go_to_categories": "카테고리",
"page.keyboard_shortcuts.go_to_feed": "피드",
"page.keyboard_shortcuts.go_to_feeds": "피드 목록",
"page.keyboard_shortcuts.go_to_history": "기록",
"page.keyboard_shortcuts.go_to_next_item": "다음 게시물",
"page.keyboard_shortcuts.go_to_next_page": "다음 페이지",
"page.keyboard_shortcuts.go_to_previous_item": "이전 게시물",
"page.keyboard_shortcuts.go_to_previous_page": "이전 페이지",
"page.keyboard_shortcuts.go_to_search": "검색 폼으로 이동",
"page.keyboard_shortcuts.go_to_settings": "설정",
"page.keyboard_shortcuts.go_to_starred": "즐겨찾기",
"page.keyboard_shortcuts.go_to_top_item": "맨 위 게시물로 이동",
"page.keyboard_shortcuts.go_to_unread": "읽지 않음",
"page.keyboard_shortcuts.mark_page_as_read": "현재 페이지의 게시물을 모두 읽음으로 표시",
"page.keyboard_shortcuts.open_comments": "댓글 링크 열기",
"page.keyboard_shortcuts.open_comments_same_window": "현재 탭에서 댓글 링크 열기",
"page.keyboard_shortcuts.open_item": "선택한 게시물 열기",
"page.keyboard_shortcuts.open_original": "원본 링크 열기",
"page.keyboard_shortcuts.open_original_same_window": "현재 탭에서 원본 링크 열기",
"page.keyboard_shortcuts.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"page.keyboard_shortcuts.remove_feed": "이 피드 삭제",
"page.keyboard_shortcuts.save_article": "게시물 저장",
"page.keyboard_shortcuts.scroll_item_to_top": "게시물이 상단에 오도록 스크롤",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "키보드 단축키 표시",
"page.keyboard_shortcuts.subtitle.actions": "작업",
"page.keyboard_shortcuts.subtitle.items": "게시물 간 이동",
"page.keyboard_shortcuts.subtitle.pages": "페이지 간 이동",
"page.keyboard_shortcuts.subtitle.sections": "섹션 이동",
"page.keyboard_shortcuts.title": "키보드 단축키",
"page.keyboard_shortcuts.toggle_star_status": "즐겨찾기 표시/해제",
"page.keyboard_shortcuts.toggle_entry_attachments": "첨부 파일 열기/닫기",
"page.keyboard_shortcuts.toggle_read_status_next": "읽음/읽지 않음 전환 후 다음 게시물로 이동",
"page.keyboard_shortcuts.toggle_read_status_prev": "읽음/읽지 않음 전환 후 이전 게시물로 이동",
"page.login.google_signin": "Google 계정으로 로그인",
"page.login.oidc_signin": "%s 계정으로 로그인",
"page.login.title": "로그인",
"page.login.webauthn_login": "패스키로 로그인",
"page.login.webauthn_login.error": "패스키로 로그인할 수 없음",
"page.new_api_key.title": "새 API 키",
"page.new_category.title": "새 카테고리",
"page.new_user.title": "새 사용자",
"page.offline.message": "오프라인입니다",
"page.offline.refresh_page": "페이지를 새로 고쳐 보세요",
"page.offline.title": "오프라인 모드",
"page.read_entry_count": [
"읽은 게시물 %d개"
],
"page.search.title": "검색 결과",
"page.sessions.table.actions": "작업",
"page.sessions.table.current_session": "현재 세션",
"page.sessions.table.date": "날짜",
"page.sessions.table.ip": "IP 주소",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "세션",
"page.settings.link_google_account": "Google 계정과 연동",
"page.settings.link_oidc_account": "%s 계정과 연동",
"page.settings.title": "설정",
"page.settings.unlink_google_account": "Google 계정과 연동 해제",
"page.settings.unlink_oidc_account": "%s 계정과 연동 해제",
"page.settings.webauthn.actions": "작업",
"page.settings.webauthn.added_on": "추가일",
"page.settings.webauthn.delete": [
"패스키 %d개 삭제"
],
"page.settings.webauthn.last_seen_on": "마지막 사용일",
"page.settings.webauthn.passkey_name": "패스키 이름",
"page.settings.webauthn.passkeys": "패스키 인증",
"page.settings.webauthn.register": "패스키 등록",
"page.settings.webauthn.register.error": "패스키를 등록할 수 없습니다",
"page.shared_entries.title": "공유 게시물",
"page.shared_entries_count": [
"공유 게시물 %d개"
],
"page.starred.title": "즐겨찾기",
"page.starred_entry_count": [
"즐겨찾기 표시된 게시물 %d개"
],
"page.total_entry_count": [
"총 게시물 %d개"
],
"page.unread.title": "읽지 않음",
"page.unread_entry_count": [
"읽지 않은 게시물 %d개"
],
"page.users.actions": "작업",
"page.users.admin.no": "아니오",
"page.users.admin.yes": "예",
"page.users.is_admin": "관리자",
"page.users.last_login": "마지막 로그인",
"page.users.never_logged": "로그인 기록 없음",
"page.users.title": "사용자 목록",
"page.users.username": "사용자명",
"page.webauthn_rename.title": "패스키 이름 변경",
"pagination.first": "처음",
"pagination.last": "마지막",
"pagination.next": "다음",
"pagination.previous": "이전",
"search.label": "검색",
"search.placeholder": "… 검색",
"search.submit": "검색",
"skip_to_content": "콘텐츠로 건너뛰기",
"time_elapsed.days": [
"%d일 전"
],
"time_elapsed.hours": [
"%d시간 전"
],
"time_elapsed.minutes": [
"%d분 전"
],
"time_elapsed.months": [
"%d개월 전"
],
"time_elapsed.not_yet": "미래",
"time_elapsed.now": "지금",
"time_elapsed.weeks": [
"%d주 전"
],
"time_elapsed.years": [
"%d년 전"
],
"time_elapsed.yesterday": "어제",
"tooltip.keyboard_shortcuts": "키보드 단축키: %s",
"tooltip.logged_user": "%s로 로그인 중"
}
+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.
-10
View File
@@ -66,8 +66,6 @@ type UserModificationRequest struct {
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
@@ -134,14 +132,6 @@ func (u *UserModificationRequest) Patch(user *User) {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
if u.OpenIDConnectID != nil {
user.OpenIDConnectID = *u.OpenIDConnectID
}
if u.EntriesPerPage != nil {
user.EntriesPerPage = *u.EntriesPerPage
}
+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)
}
}
+44 -29
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
@@ -97,7 +89,7 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
}
func (a atomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
var links []*AtomLink
links := make([]*AtomLink, 0, len(a))
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
@@ -134,22 +126,45 @@ type atomCategory struct {
Label string `xml:"label,attr"`
}
type atomCategories []atomCategory
func (ac atomCategories) CategoryNames() []string {
var categories []string
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
}
}
+4 -2
View File
@@ -312,6 +312,8 @@ var replacer = strings.NewReplacer(
"Thurs,", "Thu,",
"Thur,", "Thu,",
)
var losAngelesLocation, _ = time.LoadLocation("America/Los_Angeles")
var newYorkLocation, _ = time.LoadLocation("America/New_York")
// Parse parses a given date string using a large
// list of commonly found feed date formats.
@@ -352,9 +354,9 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Workaround for dates that don't use GMT.
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
loc, _ = time.LoadLocation("America/Los_Angeles")
loc = losAngelesLocation
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
loc, _ = time.LoadLocation("America/New_York")
loc = newYorkLocation
}
return time.ParseInLocation(layout, ds, loc)
@@ -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"`
+93 -64
View File
@@ -4,6 +4,7 @@
package json // import "miniflux.app/v2/internal/reader/json"
import (
"cmp"
"log/slog"
"slices"
"strings"
@@ -56,38 +57,41 @@ 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} {
value = strings.TrimSpace(value)
if value != "" {
entry.Title = sanitizer.TruncateHTML(value, 100)
break
if value = sanitizer.TruncateHTML(value, 100); value == "" {
continue
}
entry.Title = value
break
}
}
@@ -98,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 []string
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} {
@@ -182,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()
+3 -7
View File
@@ -22,7 +22,7 @@ type rdfAdapter struct {
func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
feed := &model.Feed{
Title: stripTags(r.rdf.Channel.Title),
Title: sanitizer.StripTags(r.rdf.Channel.Title),
FeedURL: strings.TrimSpace(baseURL),
SiteURL: strings.TrimSpace(r.rdf.Channel.Link),
Description: strings.TrimSpace(r.rdf.Channel.Description),
@@ -95,9 +95,9 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
// Populate the entry author.
switch {
case item.DublinCoreCreator != "":
entry.Author = stripTags(item.DublinCoreCreator)
entry.Author = sanitizer.StripTags(item.DublinCoreCreator)
case r.rdf.Channel.DublinCoreCreator != "":
entry.Author = stripTags(r.rdf.Channel.DublinCoreCreator)
entry.Author = sanitizer.StripTags(r.rdf.Channel.DublinCoreCreator)
}
feed.Entries = append(feed.Entries, entry)
@@ -105,7 +105,3 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
return feed
}
func stripTags(value string) string {
return strings.TrimSpace(sanitizer.StripTags(value))
}
+24 -17
View File
@@ -5,7 +5,6 @@
package readingtime
import (
"math"
"strings"
"unicode"
"unicode/utf8"
@@ -15,13 +14,15 @@ import (
// EstimateReadingTime returns the estimated reading time of an article in minute.
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
sanitizedContent := sanitizer.StripTags(content)
truncationPoint := min(len(sanitizedContent), 50)
const truncationPoint = 100
if isCJK(sanitizedContent[:truncationPoint]) {
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
sanitizedContent := sanitizer.StripTags(content)
if isCJK(sanitizedContent, truncationPoint) {
return (utf8.RuneCountInString(sanitizedContent) + cjkReadingSpeed - 1) / cjkReadingSpeed
}
return int(math.Ceil(float64(countWords(sanitizedContent)) / float64(defaultReadingSpeed)))
return (countWords(sanitizedContent) + defaultReadingSpeed - 1) / defaultReadingSpeed
}
func countWords(s string) int {
@@ -32,20 +33,26 @@ func countWords(s string) int {
return n
}
func isCJK(text string) bool {
totalCJK := 0
func isCJK(text string, limit int) bool {
var letters, totalCJK int
for _, r := range text {
// Numbers and control characters often used in CJK too.
// Counting them makes detection less reliable.
if !unicode.In(r, unicode.Letter) {
continue
}
for _, r := range text[:min(len(text), 50)] {
if unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hangul, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Yi, r) ||
unicode.Is(unicode.Bopomofo, r) {
if letters++; letters == limit {
break
}
if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana, unicode.Yi, unicode.Bopomofo) {
totalCJK++
}
}
// if at least 50% of the text is CJK, odds are that the text is in CJK.
return totalCJK > len(text)/50
// If at least half of the letters is CJK, odds are that the text is CJK.
midpoint := letters / 2
return totalCJK > midpoint
}
@@ -3,7 +3,10 @@
package readingtime
import "testing"
import (
"strings"
"testing"
)
var samples = map[string]string{
"shortenglish": `This is a short paragraph in english, less than 250 chars.`,
@@ -79,6 +82,20 @@ func TestEstimateReadingTime(t *testing.T) {
}
}
func TestEmptyEstimateReadingTime(t *testing.T) {
got := EstimateReadingTime("", 200, 500)
if got != 0 {
t.Errorf(`Wrong reading time, got %d instead of %d`, got, 0)
}
}
func TestRepeatedEstimateReadingTime(t *testing.T) {
got := EstimateReadingTime(strings.Repeat("word ", 200), 200, 500)
if got != 1 {
t.Errorf(`Wrong reading time, got %d instead of %d`, got, 1)
}
}
func BenchmarkEstimateReadingTime(b *testing.B) {
for b.Loop() {
for _, sample := range samples {
@@ -97,3 +114,68 @@ func TestCountWordsZeroAllocs(t *testing.T) {
t.Errorf("countWords allocated %v times, expected 0", allocs)
}
}
func Test_isCJK(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
limit int
expected bool
}{
{
name: "latin short",
input: "Lorem ipsum dolor sit amet",
limit: 100,
expected: false,
},
{
name: "latin long",
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce fermentum id sem sed commodo. Ut eget mauris eu lectus mollis aliquam.",
limit: 100,
expected: false,
},
{
name: "cyrillic",
input: "Съешь ещё этих мягких французских булок, да выпей же чаю",
limit: 50,
expected: false,
},
{
name: "japanese",
input: "政盤が力和文ぱのら仕2以第ミ討漢年レ毎開スヤ間度を時深読じどが画気握てぐゆぶ留海え転5航調ヤ運旬クヌロレ探憲御裕こぎびさ。能ど加開ゆぶ軍43地伐2施ヤ実58共験それ合氾み歳熊つごゅま手柴りらし認整ラカ死感は界後えい警立ゃがよト訪奏エシサミ携勝ヘヒコカ軽通年クタ公無せはぐ読階齢間て。",
limit: 100,
expected: true,
},
{
name: "hangul",
input: "대통령은 국가의 안위에 관계되는 중대한 교전상태에 있어서 국가를 보위하기 위하여 긴급한 조치가 필요하고 국회의 집회가 불가능한 때에 한하여 법률의 효력을 가지는 명령을 발할 수 있다. 국가는 사회적·경제적 방법으로 근로자의 고용의 증진과 적정임금의 보장에 노력하여야 하며. 국교는 인정되지 아니하며. 법률이 정한 국무위원의 순서로 그 권한을 대행한다.",
limit: 100,
expected: true,
},
{
name: "mixed but mostly non-CJK",
input: "Tofu (Japanese: 豆腐, Hepburn: Tōfu; Chinese: 豆腐; pinyin: dòufu; Korean: 두부; RR: dubu)",
limit: 100,
expected: false,
},
{
name: "mixed but just enough hangul",
input: "Korean: 국한문 혼용체; Hanja: 國漢文混用體",
limit: 100,
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := isCJK(test.input, test.limit)
if got != test.expected {
t.Errorf("isCJK(%q, %d) = %v, want %v", test.input, test.limit, got, test.expected)
}
})
}
}
+149 -124
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)
}
@@ -177,30 +179,16 @@ func findFeedAuthor(rssChannel *rssChannel) string {
return ""
}
return strings.TrimSpace(sanitizer.StripTags(author))
return sanitizer.StripTags(author)
}
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 {
@@ -296,26 +284,14 @@ func findEntryAuthor(rssItem *rssItem) string {
return ""
}
return strings.TrimSpace(sanitizer.StripTags(author))
return sanitizer.StripTags(author)
}
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},
+8 -20
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
}
}
@@ -482,9 +472,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
switch tagName {
case "math":
if attribute.Key == "xmlns" {
if value != "http://www.w3.org/1998/Math/MathML" {
value = "http://www.w3.org/1998/Math/MathML"
}
value = "http://www.w3.org/1998/Math/MathML"
}
case "img":
switch attribute.Key {
+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)
+29 -12
View File
@@ -14,22 +14,39 @@ import (
// StripTags removes all HTML/XML tags from the input string.
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
func StripTags(input string) string {
tokenizer := html.NewTokenizer(strings.NewReader(input))
var buffer strings.Builder
dst := &strings.Builder{}
src := strings.NewReader(input)
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
if errors.Is(err, io.EOF) {
return buffer.String()
}
err := stripIter(src, func(text string) bool {
dst.WriteString(text)
return true
})
if err != nil {
return ""
}
return ""
return strings.TrimSpace(dst.String())
}
// stripIter iterates over the input [io.Reader] and calls the yield function for each [html.TextToken].
// Other kinds of [html.TokenType] are skipped.
func stripIter(src io.Reader, yield func(string) bool) error {
tokenizer := html.NewTokenizer(src)
for tokenizer.Next() != html.ErrorToken {
token := tokenizer.Token()
if token.Type != html.TextToken {
continue
}
token := tokenizer.Token()
if token.Type == html.TextToken {
buffer.WriteString(token.Data)
if !yield(token.Data) {
break
}
}
if err := tokenizer.Err(); !errors.Is(err, io.EOF) {
return err
}
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ import "testing"
func TestStripTags(t *testing.T) {
input := `This <a href="/test.html">link is relative</a> and <strong>this</strong> image: <img src="../folder/image.png"/>`
expected := `This link is relative and this image: `
expected := `This link is relative and this image:`
output := StripTags(input)
if expected != output {
+77 -10
View File
@@ -3,19 +3,86 @@
package sanitizer
import "strings"
import (
"strings"
"unicode"
"unicode/utf8"
)
func TruncateHTML(input string, max int) string {
text := StripTags(input)
// TruncateHTML returns cleaned up and shortened version of input.
// - HTML tags are removed
// - Consecutive whitespace characters replaced with single SPACE (0x20) character
// - If input has more runes than limit, it's truncated
func TruncateHTML(input string, limit int) string {
dst := &strings.Builder{}
src := strings.NewReader(input)
// Collapse multiple spaces into a single space
text = strings.Join(strings.Fields(text), " ")
words := 0
count := 0
needspace := false
// Convert to runes to be safe with unicode
runes := []rune(text)
if len(runes) > max {
return strings.TrimSpace(string(runes[:max])) + "…"
err := stripIter(src, func(token string) bool {
// Skip leading space.
if words > 0 {
// Add a space between tokens if there's one before HTML tag.
r, _ := utf8.DecodeRuneInString(token)
needspace = needspace || unicode.IsSpace(r)
}
for word := range strings.FieldsSeq(token) {
if needspace {
if count += 1; count > limit {
return false
}
}
// Compute how much of the word we can use later.
wordlen := 0
for wordlen < len(word) {
if count += 1; count > limit {
break
}
r, w := utf8.DecodeRuneInString(word[wordlen:])
if r == utf8.RuneError {
wordlen += 1
continue
}
wordlen += w
}
// This is the only place where space being placed.
// That way any sequence of space characters ends up as a singular SPACE (0x20) character.
//
// wordlen > 0 skips spaces if no printable characters left.
if needspace && wordlen > 0 {
dst.WriteByte(' ')
}
dst.WriteString(word[:wordlen])
if count > limit {
return false
}
needspace = true // To insert spaces in-between words in a token.
words++
}
// Add a space between tokens if there's one after HTML tag.
r, _ := utf8.DecodeLastRuneInString(token)
needspace = unicode.IsSpace(r) && words > 0
return true
})
if err != nil {
return ""
}
return text
if count > limit {
dst.WriteRune('…')
}
return dst.String()
}
+144 -60
View File
@@ -3,73 +3,49 @@
package sanitizer
import "testing"
import (
"os"
"strconv"
"testing"
)
func TestTruncateHTMWithTextLowerThanLimitL(t *testing.T) {
input := `This is a <strong>bug 🐛</strong>.`
expected := `This is a bug 🐛.`
output := TruncateHTML(input, 50)
if expected != output {
t.Errorf(`Wrong output: %q != %q`, expected, output)
}
}
func TestTruncateHTMLWithTextAboveLimit(t *testing.T) {
input := `This is <strong>HTML</strong>.`
expected := `This…`
output := TruncateHTML(input, 4)
if expected != output {
t.Errorf(`Wrong output: %q != %q`, expected, output)
}
}
func TestTruncateHTMLWithUnicodeTextAboveLimit(t *testing.T) {
input := `This is a <strong>bike 🚲</strong>.`
expected := `This…`
output := TruncateHTML(input, 4)
if expected != output {
t.Errorf(`Wrong output: %q != %q`, expected, output)
}
}
func TestTruncateHTMLWithMultilineTextAboveLimit(t *testing.T) {
input := `
This is a <strong>bike
🚲</strong>.
`
expected := `This is a bike…`
output := TruncateHTML(input, 15)
if expected != output {
t.Errorf(`Wrong output: %q != %q`, expected, output)
}
}
func TestTruncateHTMLWithMultilineTextLowerThanLimit(t *testing.T) {
input := `
This is a <strong>bike
🚲</strong>.
`
expected := `This is a bike 🚲.`
output := TruncateHTML(input, 20)
if expected != output {
t.Errorf(`Wrong output: %q != %q`, expected, output)
}
}
func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
func TestTruncateHTML(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{
name: "text lower than limit",
input: "This is a <strong>bug 🐛</strong>.",
maxLen: 50,
expected: "This is a bug 🐛.",
},
{
name: "text above limit",
input: "This is <strong>HTML</strong>.",
maxLen: 4,
expected: "This…",
},
{
name: "unicode text above limit",
input: "This is a <strong>bike 🚲</strong>.",
maxLen: 4,
expected: "This…",
},
{
name: "multiline text above limit",
input: "\n\t\tThis is a <strong>bike\n\t\t🚲</strong>.\n\n\t",
maxLen: 15,
expected: "This is a bike…",
},
{
name: "multiline text lower than limit",
input: "\n\t\tThis is a <strong>bike\n 🚲</strong>.\n\n\t",
maxLen: 20,
expected: "This is a bike 🚲.",
},
{
name: "multiple spaces",
input: "hello world test",
@@ -100,6 +76,72 @@ func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
maxLen: 20,
expected: "hello world",
},
{
name: "just enough characters",
input: "Hello",
maxLen: 5,
expected: "Hello",
},
{
name: "just enough unicode characters",
input: "Привет",
maxLen: 6,
expected: "Привет",
},
{
name: "spaces around tag",
input: "hello <br/> world",
maxLen: 20,
expected: "hello world",
},
{
name: "leading spaces",
input: " hello world",
maxLen: 5,
expected: "hello…",
},
{
name: "text above limit with space at the end",
input: "hello world",
maxLen: 6,
expected: "hello…",
},
{
name: "leading space before tag",
input: " <a>hello</a>",
maxLen: 15,
expected: "hello",
},
{
name: "space-only tokens in between tags",
input: "hello <br/>\t<a> </a>world",
maxLen: 15,
expected: "hello world",
},
{
name: "truncate mid-word",
input: "hello world",
maxLen: 8,
expected: "hello wo…",
},
{
name: "truncate mid-word with unicode",
input: "Съешь ещё этих мягких французских булок, да выпей же чаю",
maxLen: 25,
expected: "Съешь ещё этих мягких фра…",
},
{
name: "negative limit",
input: "whatever",
maxLen: -10,
expected: "…",
},
{
name: "zero limit",
input: "whatever",
maxLen: 0,
expected: "…",
},
}
for _, tt := range tests {
@@ -112,3 +154,45 @@ func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
})
}
}
func BenchmarkTruncateHTML(b *testing.B) {
benches := []struct {
filename string
limit int
}{
{
filename: "miniflux_github.html",
limit: 100,
},
{
filename: "miniflux_github.html",
limit: 10_000,
},
{
filename: "miniflux_wikipedia.html",
limit: 100,
},
{
filename: "miniflux_wikipedia.html",
limit: 100_000,
},
}
for _, f := range benches {
data, err := os.ReadFile("testdata/" + f.filename)
if err != nil {
b.Fatalf(`Unable to read file %q: %v`, f.filename, err)
}
b.Run(f.filename+"_"+strconv.Itoa(f.limit), func(b *testing.B) {
var junk string
str := string(data)
for b.Loop() {
junk = TruncateHTML(str, 100)
}
_ = junk
})
}
}
+74 -52
View File
@@ -125,62 +125,77 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
}
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL string, doc *goquery.Document) (Subscriptions, *locale.LocalizedErrorWrapper) {
queries := map[string]string{
"link[type='application/rss+xml']": parser.FormatRSS,
"link[type='application/atom+xml']": parser.FormatAtom,
"link[type='application/feed+json']": parser.FormatJSON,
// Ignore JSON feed URLs that contain "/wp-json/" to avoid confusion
// with WordPress REST API endpoints.
"link[type='application/json']:not([href*='/wp-json/'])": parser.FormatJSON,
}
var subscriptions Subscriptions
subscriptionURLs := make(map[string]bool)
for feedQuerySelector, feedFormat := range queries {
doc.Find(feedQuerySelector).Each(func(i int, s *goquery.Selection) {
subscription := new(subscription)
subscription.Type = feedFormat
// There are 4 possible feed formats
subscriptionURLs := make(map[string]bool, 4)
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
var err error
subscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
if err != nil {
return
}
} else {
return // without an url, there can be no subscription.
// Single DOM walk over every <link> with a type attribute, then dispatch on
// the MIME type. This is better than doing a separate goquery.Find pass per
// type.
doc.Find("link[type]").Each(func(_ int, s *goquery.Selection) {
typeAttr, _ := s.Attr("type")
var feedFormat string
switch typeAttr {
case "application/rss+xml":
feedFormat = parser.FormatRSS
case "application/atom+xml":
feedFormat = parser.FormatAtom
case "application/feed+json":
feedFormat = parser.FormatJSON
case "application/json":
// Ignore JSON feed URLs that contain "/wp-json/" to avoid confusion
// with WordPress REST API endpoints.
if href, _ := s.Attr("href"); strings.Contains(href, "/wp-json/") {
return
}
feedFormat = parser.FormatJSON
default:
return
}
if title, exists := s.Attr("title"); exists {
subscription.Title = title
}
feedURL, _ := s.Attr("href")
if feedURL == "" {
return // without an url, there can be no subscription.
}
if subscription.Title == "" {
subscription.Title = subscription.URL
}
absoluteURL, err := urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
if err != nil {
return
}
if !subscriptionURLs[subscription.URL] {
subscriptionURLs[subscription.URL] = true
subscriptions = append(subscriptions, subscription)
}
if subscriptionURLs[absoluteURL] {
return
}
subscriptionURLs[absoluteURL] = true
title, _ := s.Attr("title")
if title == "" {
title = absoluteURL
}
subscriptions = append(subscriptions, &subscription{
Type: feedFormat,
Title: title,
URL: absoluteURL,
})
}
})
return subscriptions, nil
}
func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
knownURLs := map[string]string{
"atom.xml": parser.FormatAtom,
"feed.atom": parser.FormatAtom,
"feed.xml": parser.FormatAtom,
"feed/": parser.FormatAtom,
"index.rss": parser.FormatRSS,
"index.xml": parser.FormatRSS,
"rss.xml": parser.FormatRSS,
"rss/": parser.FormatRSS,
"rss/feed.xml": parser.FormatRSS,
knownURLs := [...]struct {
path, format string
}{
{"atom.xml", parser.FormatAtom},
{"feed.atom", parser.FormatAtom},
{"feed.xml", parser.FormatAtom},
{"feed/", parser.FormatAtom},
{"index.rss", parser.FormatRSS},
{"index.xml", parser.FormatRSS},
{"rss.xml", parser.FormatRSS},
{"rss/", parser.FormatRSS},
{"rss/feed.xml", parser.FormatRSS},
}
websiteURLRoot := urllib.RootURL(websiteURL)
@@ -197,8 +212,8 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
var subscriptions Subscriptions
for _, baseURL := range baseURLs {
for knownURL, kind := range knownURLs {
fullURL, err := urllib.ResolveToAbsoluteURL(baseURL, knownURL)
for _, known := range knownURLs {
fullURL, err := urllib.ResolveToAbsoluteURL(baseURL, known.path)
if err != nil {
continue
}
@@ -206,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()
@@ -227,7 +245,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
}
subscriptions = append(subscriptions, &subscription{
Type: kind,
Type: known.format,
Title: fullURL,
URL: fullURL,
})
@@ -319,12 +337,16 @@ func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Su
// findCanonicalURL extracts the canonical URL from the HTML <link rel="canonical"> tag.
// Returns the canonical URL if found, otherwise returns the effective URL.
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, baseURL string, doc *goquery.Document) string {
canonicalHref, exists := doc.Find("head link[rel='canonical' i]").First().Attr("href")
if !exists || strings.TrimSpace(canonicalHref) == "" {
canonicalHref, exists := doc.FindMatcher(goquery.Single("head link[rel='canonical' i]")).Attr("href")
if !exists {
return effectiveURL
}
canonicalHref = strings.TrimSpace(canonicalHref)
if canonicalHref == "" {
return effectiveURL
}
canonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, strings.TrimSpace(canonicalHref))
canonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, canonicalHref)
if err != nil {
return effectiveURL
}
+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 -16
View File
@@ -47,11 +47,6 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
return results, nil
}
// NewEntryQueryBuilder returns a new EntryQueryBuilder
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
return NewEntryQueryBuilder(s, userID)
}
// UpdateEntryTitleAndContent updates entry title and content.
func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
@@ -456,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
}
+35 -20
View File
@@ -10,12 +10,13 @@ import (
"strconv"
"strings"
"github.com/lib/pq"
"miniflux.app/v2/internal/model"
)
// entryPaginationBuilder is a builder for entry prev/next queries.
type entryPaginationBuilder struct {
store *Storage
db *sql.DB
conditions []string
args []any
entryID int64
@@ -24,77 +25,91 @@ type entryPaginationBuilder struct {
}
// WithSearchQuery adds full-text search query to the condition.
func (e *entryPaginationBuilder) WithSearchQuery(query string) {
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)
}
return e
}
// WithStarred adds starred to the condition.
func (e *entryPaginationBuilder) WithStarred() {
func (e *entryPaginationBuilder) WithStarred() *entryPaginationBuilder {
e.conditions = append(e.conditions, "e.starred is true")
return e
}
// WithFeedID adds feed_id to the condition.
func (e *entryPaginationBuilder) WithFeedID(feedID int64) {
func (e *entryPaginationBuilder) WithFeedID(feedID int64) *entryPaginationBuilder {
if feedID != 0 {
e.conditions = append(e.conditions, "e.feed_id = $"+strconv.Itoa(len(e.args)+1))
e.args = append(e.args, feedID)
}
return e
}
// WithCategoryID adds category_id to the condition.
func (e *entryPaginationBuilder) WithCategoryID(categoryID int64) {
func (e *entryPaginationBuilder) WithCategoryID(categoryID int64) *entryPaginationBuilder {
if categoryID != 0 {
e.conditions = append(e.conditions, "f.category_id = $"+strconv.Itoa(len(e.args)+1))
e.args = append(e.args, categoryID)
}
return e
}
// WithStatus adds status to the condition.
func (e *entryPaginationBuilder) WithStatus(status string) {
func (e *entryPaginationBuilder) WithStatus(status string) *entryPaginationBuilder {
if status != "" {
e.conditions = append(e.conditions, "e.status = $"+strconv.Itoa(len(e.args)+1))
e.args = append(e.args, status)
}
return e
}
// WithStatusOrEntryID adds a status condition that always includes a specific entry ID.
func (e *entryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) {
func (e *entryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) *entryPaginationBuilder {
if status == "" {
return
return e
}
if entryID == 0 {
e.WithStatus(status)
return
return e
}
statusArg := len(e.args) + 1
entryArg := len(e.args) + 2
e.conditions = append(e.conditions, fmt.Sprintf("(e.status = $%d OR e.id = $%d)", statusArg, entryArg))
e.args = append(e.args, status, entryID)
return e
}
func (e *entryPaginationBuilder) WithTags(tags []string) {
func (e *entryPaginationBuilder) WithTags(tags []string) *entryPaginationBuilder {
if len(tags) > 0 {
for _, tag := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, tag)
}
e.conditions = append(e.conditions, fmt.Sprintf("LOWER(e.tags::text)::text[] @> LOWER($%d::text)::text[]", len(e.args)+1))
e.args = append(e.args, pq.Array(tags))
}
return e
}
// WithGloballyVisible adds global visibility to the condition.
func (e *entryPaginationBuilder) WithGloballyVisible() {
func (e *entryPaginationBuilder) WithGloballyVisible() *entryPaginationBuilder {
e.conditions = append(e.conditions, "not c.hide_globally")
e.conditions = append(e.conditions, "not f.hide_globally")
return e
}
// Entries returns previous and next entries.
func (e *entryPaginationBuilder) Entries() (*model.Entry, *model.Entry, error) {
tx, err := e.store.db.Begin()
tx, err := e.db.Begin()
if err != nil {
return nil, nil, fmt.Errorf("begin transaction for entry pagination: %v", err)
}
@@ -186,13 +201,13 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
}
// NewEntryPaginationBuilder returns a new EntryPaginationBuilder.
func NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {
func (s *Storage) NewEntryPaginationBuilder(userID, entryID int64, order, direction string) *entryPaginationBuilder {
return &entryPaginationBuilder{
store: store,
db: s.db,
args: []any{userID},
conditions: []string{"e.user_id = $1"},
entryID: entryID,
order: order,
order: pq.QuoteIdentifier(order),
direction: direction,
}
}
+51 -44
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.WithSorting(
fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001", nArgs),
"DESC",
e.sortExpressions = append(e.sortExpressions,
fmt.Sprintf("ts_rank(document_vectors, websearch_to_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
)
}
return e
@@ -115,7 +115,7 @@ func (e *EntryQueryBuilder) AfterEntryID(entryID int64) *EntryQueryBuilder {
}
// WithEntryIDs filter by entry IDs.
func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithEntryIDs(entryIDs ...int64) *EntryQueryBuilder {
if len(entryIDs) == 1 {
e.conditions = append(e.conditions, fmt.Sprintf("e.id = $%d", len(e.args)+1))
e.args = append(e.args, entryIDs[0])
@@ -126,15 +126,6 @@ func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
return e
}
// WithEntryID filter by entry ID.
func (e *EntryQueryBuilder) WithEntryID(entryID int64) *EntryQueryBuilder {
if entryID != 0 {
e.conditions = append(e.conditions, "e.id = $"+strconv.Itoa(len(e.args)+1))
e.args = append(e.args, entryID)
}
return e
}
// WithFeedID filter by feed ID.
func (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder {
if feedID > 0 {
@@ -153,17 +144,8 @@ func (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder
return e
}
// WithStatus filter by entry status.
func (e *EntryQueryBuilder) WithStatus(status string) *EntryQueryBuilder {
if status != "" {
e.conditions = append(e.conditions, "e.status = $"+strconv.Itoa(len(e.args)+1))
e.args = append(e.args, status)
}
return e
}
// WithStatuses filter by a list of entry statuses.
func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithStatuses(statuses ...string) *EntryQueryBuilder {
if len(statuses) == 1 {
e.conditions = append(e.conditions, fmt.Sprintf("e.status = $%d", len(e.args)+1))
e.args = append(e.args, statuses[0])
@@ -175,12 +157,10 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
}
// WithTags filter by a list of entry tags.
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithTags(tags ...string) *EntryQueryBuilder {
if len(tags) > 0 {
for _, cat := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, cat)
}
e.conditions = append(e.conditions, fmt.Sprintf("LOWER(e.tags::text)::text[] @> LOWER($%d::text)::text[]", len(e.args)+1))
e.args = append(e.args, pq.Array(tags))
}
return e
}
@@ -209,7 +189,13 @@ func (e *EntryQueryBuilder) WithShareCodeNotEmpty() *EntryQueryBuilder {
// WithSorting add a sort expression.
func (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBuilder {
e.sortExpressions = append(e.sortExpressions, column+" "+direction)
switch {
case strings.EqualFold(direction, "ASC"):
e.sortExpressions = append(e.sortExpressions, pq.QuoteIdentifier(column)+" ASC")
case strings.EqualFold(direction, "DESC"):
e.sortExpressions = append(e.sortExpressions, pq.QuoteIdentifier(column)+" DESC")
}
return e
}
@@ -221,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 {
@@ -264,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
}
@@ -335,15 +329,15 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
u.timezone
FROM
entries e
LEFT JOIN
INNER JOIN
feeds f ON f.id=e.feed_id
LEFT JOIN
INNER JOIN
categories c ON c.id=f.category_id
LEFT JOIN
feed_icons fi ON fi.feed_id=f.id
LEFT JOIN
icons i ON i.id=fi.icon_id
LEFT JOIN
INNER JOIN
users u ON u.id=e.user_id
WHERE ` + e.buildCondition() + " " + e.buildSorting()
@@ -410,7 +404,6 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
}
err := rows.Scan(dest...)
if err != nil {
return nil, 0, fmt.Errorf("store: unable to fetch entry row: %v", err)
}
@@ -440,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)
}
@@ -477,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"
@@ -519,17 +526,17 @@ func (e *EntryQueryBuilder) buildSorting() string {
}
// NewEntryQueryBuilder returns a new EntryQueryBuilder.
func NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder {
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
return &EntryQueryBuilder{
store: store,
store: s,
args: []any{userID},
conditions: []string{"e.user_id = $1"},
}
}
// NewAnonymousQueryBuilder returns a new EntryQueryBuilder suitable for anonymous users.
func NewAnonymousQueryBuilder(store *Storage) *EntryQueryBuilder {
func (s *Storage) NewAnonymousQueryBuilder() *EntryQueryBuilder {
return &EntryQueryBuilder{
store: store,
store: s,
}
}
+17 -20
View File
@@ -126,9 +126,9 @@ func (s *Storage) CountAllFeedsWithErrors() (int, error) {
// Feeds returns all feeds that belong to the given user.
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
return builder.GetFeeds()
return s.NewFeedQueryBuilder(userID).
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection).
GetFeeds()
}
func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
@@ -142,27 +142,26 @@ func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
// FeedsWithCounters returns all feeds of the given user with read and unread entry counters.
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCounters()
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
return getFeedsSorted(builder)
return getFeedsSorted(s.NewFeedQueryBuilder(userID).
WithCounters().
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection))
}
// FetchCounters returns the per-feed read and unread entry counts for the given user.
func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCounters()
reads, unreads, err := builder.fetchFeedCounter()
reads, unreads, err := s.NewFeedQueryBuilder(userID).
WithCounters().
fetchFeedCounter()
return model.FeedCounters{ReadCounters: reads, UnreadCounters: unreads}, err
}
// FeedsByCategoryWithCounters returns all feeds in the given category for the given user with read and unread entry counters.
func (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCategoryID(categoryID)
builder.WithCounters()
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
return getFeedsSorted(builder)
return getFeedsSorted(s.NewFeedQueryBuilder(userID).
WithCategoryID(categoryID).
WithCounters().
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection))
}
// WeeklyFeedEntryCount returns the weekly entry count for a feed.
@@ -199,9 +198,9 @@ func (s *Storage) WeeklyFeedEntryCount(userID, feedID int64) (int, error) {
// FeedByID returns the feed with the given ID.
func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithFeedID(feedID)
feed, err := builder.GetFeed()
feed, err := s.NewFeedQueryBuilder(userID).
WithFeedID(feedID).
GetFeed()
switch {
case errors.Is(err, sql.ErrNoRows):
@@ -417,7 +416,6 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.ID,
feed.UserID,
)
if err != nil {
return fmt.Errorf(`store: unable to update feed #%d (%s): %v`, feed.ID, feed.FeedURL, err)
}
@@ -446,7 +444,6 @@ func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
feed.ID,
feed.UserID,
)
if err != nil {
return fmt.Errorf(`store: unable to update feed error #%d (%s): %v`, feed.ID, feed.FeedURL, err)
}
+15 -9
View File
@@ -9,13 +9,14 @@ import (
"strconv"
"strings"
"github.com/lib/pq"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/timezone"
)
// feedQueryBuilder builds a SQL query to fetch feeds.
type feedQueryBuilder struct {
store *Storage
db *sql.DB
args []any
conditions []string
sortExpressions []string
@@ -28,9 +29,9 @@ type feedQueryBuilder struct {
}
// NewFeedQueryBuilder returns a new FeedQueryBuilder.
func NewFeedQueryBuilder(store *Storage, userID int64) *feedQueryBuilder {
func (s *Storage) NewFeedQueryBuilder(userID int64) *feedQueryBuilder {
return &feedQueryBuilder{
store: store,
db: s.db,
args: []any{userID},
conditions: []string{"f.user_id = $1"},
counterArgs: []any{userID, model.EntryStatusRead, model.EntryStatusUnread},
@@ -67,7 +68,13 @@ func (f *feedQueryBuilder) WithCounters() *feedQueryBuilder {
// WithSorting add a sort expression.
func (f *feedQueryBuilder) WithSorting(column, direction string) *feedQueryBuilder {
f.sortExpressions = append(f.sortExpressions, column+" "+direction)
switch {
case strings.EqualFold(direction, "ASC"):
f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" ASC")
case strings.EqualFold(direction, "DESC"):
f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" DESC")
}
return f
}
@@ -130,7 +137,7 @@ func (f *feedQueryBuilder) GetFeed() (*model.Feed, error) {
// GetFeeds returns a list of feeds that match the condition.
func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
var query = `
query := `
SELECT
f.id,
f.feed_url,
@@ -199,7 +206,7 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
return nil, err
}
rows, err := f.store.db.Query(query, f.args...)
rows, err := f.db.Query(query, f.args...)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch feeds: %w`, err)
}
@@ -261,7 +268,6 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
&feed.ProxyURL,
&feed.IgnoreEntryUpdates,
)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err)
}
@@ -312,11 +318,11 @@ func (f *feedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, rea
`
join := ""
if f.counterJoinFeeds {
join = "LEFT JOIN feeds f ON f.id=e.feed_id"
join = "INNER JOIN feeds f ON f.id=e.feed_id"
}
query = fmt.Sprintf(query, join, f.buildCounterCondition())
rows, err := f.store.db.Query(query, f.counterArgs...)
rows, err := f.db.Query(query, f.counterArgs...)
if err != nil {
return nil, nil, fmt.Errorf(`store: unable to fetch feed counts: %w`, err)
}
+4 -4
View File
@@ -78,8 +78,8 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
icons.content,
icons.external_id
FROM icons
LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
INNER JOIN feeds ON feeds.id=feed_icons.feed_id
WHERE
feeds.user_id=$1 AND feeds.id=$2
LIMIT 1
@@ -175,8 +175,8 @@ func (s *Storage) Icons(userID int64) (model.Icons, error) {
icons.content,
icons.external_id
FROM icons
LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
INNER JOIN feeds ON feeds.id=feed_icons.feed_id
WHERE
feeds.user_id=$1
`
+5 -1
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
@@ -513,7 +515,7 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
u.open_external_links_in_new_tab
FROM
users u
LEFT JOIN
INNER JOIN
api_keys ON api_keys.user_id=u.id
WHERE
api_keys.token = $1
@@ -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},
}
}

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