Compare commits

...

274 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
jvoisin 20545d28e9 refactor(storage): remove a useless ORDER BY in GetEnclosure
The query selects a single row by primary key, there is no need to sort
anything.
2026-05-13 19:23:01 -07:00
dependabot[bot] 771977407c build(deps): bump the gomod group with 6 updates
Bumps the gomod group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) | `0.17.2` | `0.17.3` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.50.0` | `0.51.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.39.0` | `0.40.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.53.0` | `0.54.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.42.0` | `0.43.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.36.0` | `0.37.0` |


Updates `github.com/go-webauthn/webauthn` from 0.17.2 to 0.17.3
- [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.2...v0.17.3)

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

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

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

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

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

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.17.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/term
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/text
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 16:42:27 -07:00
Fred 0642e8a3ea fix(webauthn): persist backup flags
Store WebAuthn backup eligibility and backup state with each
credential instead of overwriting BackupEligible from every login
assertion.

Use a nullable backup_eligible column to identify legacy credentials
and backfill those records on their next successful login. Also persist
the validated credential state after login, including sign count, clone
warning, and backup state.
2026-05-11 20:29:27 -07:00
Fred 059ec55f52 security(webauthn)!: require discoverable passkeys
Remove the username-based WebAuthn login flow because it allowed
username enumeration before password verification.

WebAuthn login now uses discoverable credentials only, and new
registrations require resident keys. Existing non-resident credentials
are no longer usable for first-factor login; they should only be used
in a post-password MFA flow, which Miniflux does not currently
implement.

BREAKING CHANGE: Users with existing non-resident WebAuthn credentials
must register a new passkey.
2026-05-11 19:41:02 -07:00
dashitongzhi 9fd6f44311 fix: typo in wallabag integration error message
Change "unable to get save entry" to "unable to save entry"
in the wallabag createEntry error message and its corresponding
test assertion.
2026-05-11 19:39:33 -07:00
jvoisin 018128e109 fix(storage): delete orphaned icons
The icons table is deduplicated by hash and shared with feeds via the
feed_icons junction. When a feed is deleted, the ON DELETE CASCADE
removes its feed_icons row but leaves the icons row behind. The same
happens in StoreFeedIcon when a feed's icon is replaced. Over time
these orphaned bytea blobs accumulate and bloat the database.

Add Storage.CleanupOrphanIcons, which deletes icons rows that no
feed_icons row still references, and call it from runCleanupTasks.
2026-05-11 17:57:32 -07:00
gudvinr 3f05747a78 refactor(internal): use errors.Is instead of equality operator
errors.Is unwraps nested error. This makes check less error prone.
2026-05-11 17:40:12 -07:00
jvoisin 7a5b4b109e fix(googlereader): avoid inlining validateApiKey at every call site
The middleware (*authMiddleware).validateApiKey is registered for 14
routes in NewHandler. Its body was just `return http.HandlerFunc(func…)`,
which made the compiler consider it inlinable. As a result, the entire
losure body was duplicated at every call site, taking space in the .text
section.

Move the request-handling logic into a separate non-inlined method
serveValidated and keep validateApiKey as a thin wrapper that only
allocates the http.HandlerFunc. The 14 duplicated symbols are gone and
the stripped binary shrinks from 20,513,033 to 20,447,497 bytes, which isn't
that much, but it's still something, especially for such a simple commit.
2026-05-11 16:38:54 -07:00
jvoisin c2661ca1c4 perf(processor): avoid per-char string allocation in parseISO8601Duration
Replace `num += string(char)` with index-based slicing of the input
string. The previous loop allocated a new string for every digit in
the duration (and again on each `+=`), giving O(n²) allocation
behavior. Slicing `after[start:i]` reuses the original string's
backing memory and only allocates once per numeric component when
passed to `strconv.Atoi`.

It also makes the code a bit more compact/simpler.

Called once per YouTube/podcast entry that exposes an ISO8601
duration during feed processing.
2026-05-11 13:55:52 -07:00
Frédéric Guillot 30ad65e03b refactor(webauthn): tighten finishLogin and saveCredential
Surface previously-swallowed storage errors, remove a dead UserByID
lookup, and simplify the discoverable login control flow.
2026-05-09 21:20:05 -07:00
Frédéric Guillot 9a0e357842 refactor(webauthn): clean up variable naming
Drop the snake_case cred_uid, fix the missing-n typos in webAuthUser
and webAuthCredential, replace credCredential with validatedCredential,
unify uid/userID on userID, rename the shadowed url local in
newWebAuthn to baseURL, and use the full credential / credentials names
throughout instead of the abbreviated cred / creds. No behaviour change.
2026-05-09 20:52:18 -07:00
Frédéric Guillot d4f362ad31 refactor(webauthn): drop unreachable uid==0 guards
The web session middleware redirects unauthenticated requests to the
login page before any non-public handler runs, so request.UserID is
guaranteed non-zero in beginRegistration, finishRegistration, and
deleteCredential. Remove the dead checks to match the other WebAuthn
handlers.
2026-05-09 20:29:04 -07:00
jvoisin 10bdbf82b9 fix(webauthn): check ownership in saveCredential on WebAuthn rename
While both deleteCredential either validate or pass down a uid,
saveCredential doesn't. This isn't exploitable as an authenticated attacker
would need to guess the 32 bytes handle of another one, but it doesn't hurt to
explicitly check if a user is only operating on their own user.
2026-05-09 18:21:21 -07:00
Frédéric Guillot f467c2daa1 refactor(ui): require POST for OAuth2 unlink endpoint
Account unlinking mutates state, so /oauth2/{provider}/unlink can no
longer be reached via GET. Pull the OAuth2/OIDC and WebAuthn sections
out of the settings form and render each as its own fieldset above the
form, with the unlink action as a self-contained POST form. Rename
the username/password fieldset legend to "Password Authentication" and
add matching legends for the federated and passkey sections so all
authentication methods read consistently.
2026-05-09 18:19:59 -07:00
jvoisin 6b06c9f4f4 perf(readingtime): don't allocate words to count them
The function strings.Fields will allocate every single word it's creating,
meaning that for a text of 10k words, 10k allocations will be made, only for
them to be counted an discarded. We can do much better by counting the words
ourself via a small countWords helper function, and write a test to prove that
it doesn't allocate anything.
2026-05-09 16:29:33 -07:00
Frédéric Guillot 30ede1caa1 refactor(ui): require POST for feed refresh endpoints
Feed refresh endpoints mutate state and should not be reachable via
GET. Drop the GET registrations on /feeds/refresh, /feed/{id}/refresh,
/category/{id}/feeds/refresh and /category/{id}/entries/refresh, and
update the templates and the R keyboard shortcut to submit POST forms
with a CSRF token.
2026-05-08 21:08:51 -07:00
Frédéric Guillot 16902a2297 refactor(logout): require POST for /logout
Logout is a state-changing action and should not be reachable via GET.
Switching to POST routes the request through the CSRF middleware so
prefetchers and cross-site GETs can no longer terminate the session.
2026-05-08 20:39:06 -07:00
jvoisin 08de9546e3 fix(csrf): apply CSRF to all non-safe methods
Better safe than sorry
2026-05-08 16:15:44 -07:00
Mateusz Jabłoński cf7474a2d3 feat(rewrite): add enclosure links
When subscribing to podcasts or videocasts (or any feeds with enclosures), Miniflux presents audio/video controls on top, and links on bottom. But it's is not a part of the article, it is Miniflux-only UI modification. A new rewrite rule is added to expose enclosure links to the content, so that it can be accessed outsite of Miniflux, specifically on any native mobile RSS apps. That way, it is now possible to access media files to open them eg. in native media player apps.
2026-05-08 16:00:27 -07:00
jvoisin 7892cb8546 perf(reader): use a strings.Builder instead a string concatenation 2026-05-08 15:49:39 -07:00
jvoisin fc68e33681 fix(oauth2): reject empty state when no flow is in progress
The `sess.OAuth2State()` function returns "" when no flow has been initiated,
thus making `subtle.ConstantTimeCompare("", "")` return 1, making a callback
with state= passes this check.

This isn't an exploitable vulnerability, as PKCE is enforced for both Google
and OIDC (authorization.go:45-49, google.go:57-60) and the missing
code_verifier will fail at the IdP token exchange.

This was found as I was digging into Forgejo OAuth2's implementation, and
wondered how miniflux was faring.
2026-05-08 15:43:12 -07:00
jvoisin 622d757a32 perf(sanitizer): improve the sanitizer's performances twofold
Previously, every allowed attribute had to allocate memory for a slice,
concatenated in `key=…`, and a final strings.Join call. Instead of doing all of
this, a single string.Builder is used, with two simple local functions. It
doesn't significantly complexify the code, while improving the performances.
Now, I know that this isn't really a bottleneck in miniflux, but the
improvement is around 50% for the wikipedia/github benchmark (BenchmarkSanitize),
so I think it's worth it, given that the sanitizeAttributes function is call
for every attribute a feed items.

Before:

```
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/sanitizer
BenchmarkSanitizeImageHeavyNoQuery-8     	    6326	    567676 ns/op	   78305 B/op	     918 allocs/op
BenchmarkSanitizeImageHeavyWithQuery-8   	    4186	   1028312 ns/op	  124353 B/op	    1366 allocs/op
BenchmarkSanitize-8                      	      50	  21440566 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/sanitizer	9.039s
```

After:

```
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/sanitizer
BenchmarkSanitizeImageHeavyNoQuery-8     	    7512	    530973 ns/op	   73186 B/op	     790 allocs/op
BenchmarkSanitizeImageHeavyWithQuery-8   	    1173	   1023078 ns/op	  118209 B/op	    1238 allocs/op
BenchmarkSanitize-8                      	      92	  13265599 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/sanitizer	6.553s
```
2026-05-07 20:38:56 -07:00
jvoisin f74870d796 perf(template): improve a tad the rendering of icons
On the main page (showing ~every unread feed item), every item
uses at least 4 icons. For 100 unread items, that's 400 icons, meaning
that the `icon` func is called 400 times.

On my local microbenchmark, using `fmt.Sprintf` uses one dynamic allocation and
takes ~275ns. Using concatenation in a dedicated function (that gets inlined)
doesn't allocate any memory, and takes ~2.1ns. This thus saves 400 short-lived
allocations and reduces the execution time by a factor of 100.
2026-05-07 20:28:37 -07:00
Mateusz Jabłoński bad9411d63 feat(opml): include feed settings in export and import
Extend OPML export to include Miniflux-specific feed settings as custom
attributes on each outline element (scraper rules, rewrite rules, URL
rewrite rules, blocklist/keeplist rules, user agent, cookie, proxy URL,
and various boolean flags).

On import, these attributes are applied when creating new feeds, allowing
a Miniflux OPML export to serve as a full backup and restore mechanism
for feed configuration. Existing feeds are not modified to preserve the
original import semantics.
2026-05-07 20:20:05 -07:00
jvoisin b7da3634e4 perf(misc): batch navigational information queries
Obtaining the amount of unread entries, errored feeds and if the user has
integrations enabled can be done in a single query, instead of doing it one by
one. This should reduce the amount of queries from 3 or 2 to 1, depending on
the page.

This commit is touching a significant amount of files, and the
search-and-replace, while done with love and care, would benefit from a
thorough review, to ensure that nothing was subtly broken.
2026-05-07 19:35:10 -07:00
dependabot[bot] 8fc7d1f3c2 build(deps): bump github.com/go-webauthn/webauthn in the gomod group
Bumps the gomod group with 1 update: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn).


Updates `github.com/go-webauthn/webauthn` from 0.17.0 to 0.17.2
- [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.0...v0.17.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 20:40:52 -07:00
IEEE-754 7d80bbd070 fix(i18n): Improve the quality of zh-TW translations 2026-05-05 21:40:49 -07:00
jvoisin cd01413b9b perf(storage): preallocate some slices and a map 2026-05-04 20:21:22 -07:00
Frédéric Guillot 0909323ae3 feat(validator): cap entry limit at MaxEntryLimit
Reject API "limit" query parameter and "entries_per_page" preference
values above 1000, and clamp the storage entry query builder so any
caller (REST API, Google Reader, internal UI) is bounded.

The HTML settings form now exposes the cap via the input "max"
attribute.
2026-05-03 17:50:32 -07:00
Frédéric Guillot 6543d652a6 fix(sanitizer): match URI schemes case-insensitively
Per RFC 3986 §3.1, URI schemes are case-insensitive. HasValidURIScheme
previously did a literal HasPrefix check, so inputs like "HTTPS://..."
were rejected. Use strings.Cut to extract the scheme and compare each
allowlisted entry with strings.EqualFold.
2026-05-03 14:59:37 -07:00
Frédéric Guillot 64baebad6f fix(template): replace safeURL with untrustedURL scheme validator
safeURL wrapped any string in template.URL, defeating html/template's
URL filter and allowing javascript:/data: URIs from feed entries to
render verbatim.

untrustedURL validates the scheme via sanitizer.HasValidURIScheme,
falling back to "#" otherwise. The sanitizer allowlist is reused
because html/template's built-in filter is too narrow for feeds (only
http(s), mailto, and relative URLs).
2026-05-03 14:25:14 -07:00
Frédéric Guillot d5e68025d4 fix(http): validate redirect URL scheme in HTMLRedirect
Reject any URI that is not a same-origin relative path or an absolute
http(s) URL, preventing attacker-controlled feed entry URLs (e.g.
javascript:, data:, mailto:, scheme-relative //host/...) from being
emitted in a Location header.
2026-05-02 20:30:40 -07:00
NatsuCamellia e4338950a0 fix(googlereader): fix incorrect read/starred toggling
Add a !read / !starred guard to the else-if branch so that the handler
only toggles the state when the requested value differs from the
current one.
2026-05-02 13:29:09 -07:00
jvoisin c395924fa0 perf(sanitizer): don't sanitize parameters when they're no parameters 2026-05-02 10:37:54 -07:00
dependabot[bot] 9467d5deb9 build(deps): bump github/codeql-action in the github-actions group
Bumps the github-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.35.2 to 4.35.3
- [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/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...e46ed2cbd01164d986452f91f178727624ae40d7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 10:31:12 -07:00
jvoisin 55821b9fba perf(reader): preallocate some slices/maps
4 to 1, with a meaningful ns/op win on hot feed-parse paths. personNames trades
a slightly larger fixed allocation for fewer growths and gets ~29% faster.
The micro-benchmarks used to obtain those numbers are, well, micro-benchmarks,
and thus I don't think it would make sense to add them to miniflux.
2026-05-02 10:29:22 -07:00
dependabot[bot] 45e222632b build(deps): bump github.com/tdewolff/minify/v2 in the gomod group
Bumps the gomod group with 1 update: [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify).


Updates `github.com/tdewolff/minify/v2` from 2.24.12 to 2.24.13
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.12...v2.24.13)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-30 15:07:56 -07:00
Mateusz Jabłoński a684b6ac73 feat(sanitizer): add iOS Shortcuts shortcuts: scheme to validURISchemes
Miniflux sanitizer filters allowed URL schemes, but `shortcuts:` is not included. `shortcuts:` is an official Apple scheme (https://support.apple.com/guide/shortcuts/run-a-shortcut-from-a-url-apd624386f42/ios), similar eg. to already added `itms-apps`
2026-04-24 15:32:10 -07:00
dependabot[bot] 1e8ce07585 build(deps): bump github.com/go-webauthn/webauthn in the gomod group
Bumps the gomod group with 1 update: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn).


Updates `github.com/go-webauthn/webauthn` from 0.16.4 to 0.17.0
- [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.16.4...v0.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 17:25:19 -07:00
Frédéric Guillot 10f95e9f96 ci: pin third-party actions to commit SHAs
Replaces tag references (e.g. @v6) with the exact commit SHA and a
trailing version comment across all workflows. Pinning by SHA prevents
supply-chain risk from a tag being moved to a malicious commit.
2026-04-22 07:57:34 -07:00
Frédéric Guillot 54259c6176 ci: add workflow to close stale pull requests
Marks pull requests as stale after 60 days of inactivity and closes them
14 days later. Issues are excluded. Runs daily and can be triggered
manually via workflow_dispatch.
2026-04-21 21:10:23 -07:00
Frédéric Guillot cdf0632f08 refactor(storage): take category sort order as parameter
Previously CategoriesWithFeedCount loaded the user via UserByID just
to read CategoriesSortingOrder. Pass it in explicitly so the UI path
(which already has the user loaded) avoids a redundant lookup.
2026-04-19 20:18:53 -07:00
Frédéric Guillot 08113e50bb perf(storage): merge status update and visible count query 2026-04-19 19:42:46 -07:00
Frédéric Guillot 56c80f3085 refactor(storage): simplify user removal via ON DELETE CASCADE
The integrations table lacked a foreign key to users, forcing
RemoveUser to delete integration rows explicitly and RemoveUserAsync
to iterate over feeds to avoid a long-running transaction.

Add a migration introducing the missing ON DELETE CASCADE foreign key
and drop both workarounds. The sole caller of RemoveUserAsync inlines
the goroutine so the fire-and-forget behaviour is visible at the call
site.

Also fix godoc comments in user.go.
2026-04-19 17:25:57 -07:00
Frédéric Guillot bbd67302fa refactor(storage): simplify RemoveFeed via ON DELETE CASCADE
The previous implementation iterated over entries and issued one DELETE
per row as a workaround to avoid a long-running transaction when
removing feeds with many entries. In practice this caused N+1
round-trips and took over 3 minutes to delete a feed with 22k entries.

Rely on the ON DELETE CASCADE on entries.feed_id (and transitively
enclosures.entry_id) and issue a single DELETE on feeds. Postgres
handles large cascaded deletes efficiently with row-level locking.

Also fix grammar, accuracy, and a missing godoc comment across the
exported functions in feed.go.
2026-04-19 16:53:57 -07:00
Frédéric Guillot 2916831cb1 fix(storage): prevent deleted entries from reappearing as unread
Archived entries could be re-ingested as new unread rows when a feed
re-emitted them. Replace the "removed" soft-delete status with an
entry_tombstones table keyed on (feed_id, hash); the INSERT is guarded
by WHERE NOT EXISTS so archival and refresh can no longer race.
2026-04-18 19:35:42 -07:00
Frédéric Guillot 9702c6269f feat(config): allow disabling local auth without user creation
Lift the validation that rejected DISABLE_LOCAL_AUTH=1 combined with
OAUTH2_USER_CREATION=0 or AUTH_PROXY_USER_CREATION=0. Admins can now
pre-create users and forbid auto-registration while still forcing all
logins through OAuth2 or an auth proxy.

Fixes: #3163
2026-04-17 19:14:19 -07:00
gudvinr 42b9c7ea13 docs: update required version of Go 2026-04-15 19:33:56 -07:00
dependabot[bot] f2f9fdd867 build(deps): bump the gomod group with 6 updates
Bumps the gomod group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) | `0.16.3` | `0.16.4` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.49.0` | `0.50.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.38.0` | `0.39.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.52.0` | `0.53.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.41.0` | `0.42.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.35.0` | `0.36.0` |


Updates `github.com/go-webauthn/webauthn` from 0.16.3 to 0.16.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.16.3...v0.16.4)

Updates `golang.org/x/crypto` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/crypto/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/image` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/image/compare/v0.38.0...v0.39.0)

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

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

Updates `golang.org/x/text` from 0.35.0 to 0.36.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: golang.org/x/crypto
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/term
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/text
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-15 19:32:48 -07:00
Frédéric Guillot ee6c390aac fix(ui): rename flash message template variables
The flash message keys `successMessage` and `errorMessage` collided with
per-template form validation variables set by handlers (e.g. add
subscription), causing form errors to also render in the global flash
banner. Rename the flash keys to `flashSuccessMessage` and
`flashErrorMessage` to keep them distinct from form-scoped variables.
2026-04-13 19:22:53 -07:00
Frédéric Guillot 15a532b991 fix(fetcher): allow connections to configured private proxies
Treat user-configured proxies (feed proxy, application proxy, proxy
rotator) as trusted hops so that the private-network restriction does
not block requests routed through a proxy listening on a private
address. Direct requests and redirects still enforce the check.
2026-04-13 19:10:42 -07:00
Frédéric Guillot 45f97c6907 fix(ui): honor new-tab preference on entry titles
When "Read articles by opening external links" is enabled, the entry
title routes to an internal handler that HTTP-redirects to the external
URL. The title link had no target="_blank", so the redirect was followed
in the current tab, ignoring "Open external links in a new tab".

Add target="_blank" to the title link in every entry list template when
both preferences are enabled.

Refs: #4241
2026-04-11 20:23:26 -07:00
Frédéric Guillot deef74e75b feat(ui): add stdlib cross-origin protection middleware
Wrap the UI handler chain with http.CrossOriginProtection as the
outermost layer so cross-origin unsafe-method requests are rejected
via Sec-Fetch-Site/Origin checks before session lookup or token CSRF
validation runs. Stacks with the existing per-session token CSRF for
defense in depth; API handlers are unaffected.
2026-04-11 19:45:50 -07:00
Frédéric Guillot 182a010ea7 refactor: rewrite and simplify web sessions management 2026-04-11 19:21:59 -07:00
jvoisin 18920385ff perf(template): pre-allocate the strings.Builder in csp(…) 2026-04-10 15:41:06 -07:00
Frédéric Guillot 75753ce8b3 fix(http): sanitize filename in Content-Disposition header
Use mime.FormatMediaType to properly encode the filename parameter,
preventing header injection via unescaped double quotes in proxied
media URLs.
2026-04-09 20:52:30 -07:00
jvoisin 53095eb76c fix(server): Correctly handling slow headers 2026-04-08 20:35:49 -07:00
dependabot[bot] 6569367531 build(deps): bump github.com/coreos/go-oidc/v3 in the gomod group
Bumps the gomod group with 1 update: [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 20:34:29 -07:00
jvoisin ecd663a094 perf(reader): use a minify.M singleton
There is no need to allocate a new HTMLMinifer every time a page needs to be
minified, as HTMLMinifer is thread-safe, we can using a singleton instead.
The memory overhead is negligible, as a minify.M struct is small.
2026-04-07 20:44:16 -07:00
Frédéric Guillot 68bc5a92e2 feat(fetcher): detect Cloudflare bot challenge responses
Inspect response headers to recognize Cloudflare interstitial pages
(cf-mitigated: challenge, or 403/503 served by cloudflare with cf-ray
and an HTML body) and surface a dedicated localized error instead of a
generic HTTP failure.
2026-04-07 20:43:03 -07:00
Frédéric Guillot 2e60923236 fix(rss): disambiguate entries sharing the same guid
Some non-conformant feeds ship the same <guid> for every item, which
caused Miniflux to collapse all of them into a single entry. Keep the
first occurrence hashed as SHA256(guid) so existing stored entries
still match, and disambiguate later collisions using the entry URL
(falling back to the item position when no URL is available).
2026-04-06 20:07:40 -07:00
Frédéric Guillot 0986f73093 fix(ui): translate contributors text on about page 2026-04-06 19:16:48 -07:00
Michael Moll 76143ec1a6 feat: add linux/riscv64 build 2026-04-06 17:45:45 -07:00
Frédéric Guillot ef47db4a36 ci(debian): trigger PR workflow on workflow file changes 2026-04-06 16:46:58 -07:00
Frédéric Guillot 8bbd60dac0 ci(docker): build images on pull requests without publishing
The vars.PUBLISH_DOCKER_IMAGES gate was redundant with the existing
repository_owner check and the push condition on the build steps, so
it has been removed.
2026-04-06 16:43:57 -07:00
jvoisin 0172d31694 perf(ui): don't ask postgres for entries content if we're not using it
No need for postgresql to look up and send our way entries content if we're not
going to make use of it in the first place
2026-04-06 16:38:12 -07:00
Frédéric Guillot 1b7af66fdc chore(dependabot): reduce update noise with grouping and slower cadence 2026-04-06 16:34:43 -07:00
dependabot[bot] de9cd88a06 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.11 to 2.24.12
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.11 to 2.24.12.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.11...v2.24.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 16:34:01 -07:00
dependabot[bot] b2316442cf build(deps): bump github.com/go-webauthn/webauthn from 0.16.2 to 0.16.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.16.2 to 0.16.3.
- [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.16.2...v0.16.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 16:28:37 -07:00
Frédéric Guillot e0b1ec24e3 ci(build): compile binaries on pull requests without uploading 2026-04-06 16:19:40 -07:00
Frédéric Guillot bca47d7dfb ci(docker): trigger PR workflow on workflow file changes 2026-04-06 13:33:14 -07:00
Luca Andrea Rossi 19f72e8ea2 docs(docker-compose): add restart policy to postgres in basic example 2026-04-06 10:30:30 -07:00
Frédéric Guillot 26d9195d21 perf(storage): use scalar comparison for single-element slices in query builder 2026-04-03 19:36:54 -07:00
jvoisin 5f3049d1ce perf(storage): factorize away a query
In InsertEntryForFeed, there is no need to check if an entry exists and then
get its hash. Instead, try to get its hash, and consider the ErrNoRows error as
an existence check.
2026-04-03 19:21:54 -07:00
dependabot[bot] c591ea335f build(deps): bump github.com/lib/pq from 1.12.2 to 1.12.3
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.12.2 to 1.12.3.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.12.2...v1.12.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 17:37:52 -07:00
dependabot[bot] 8df6b823ee build(deps): bump github.com/go-jose/go-jose/v4 from 4.1.3 to 4.1.4
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.1.3 to 4.1.4.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.3...v4.1.4)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 20:57:49 -07:00
Frédéric Guillot fd75311621 refactor(ui): skip app session creation for static asset routes
Extract isStaticAssetRoute() to identify routes that do not need an app
session and reuse it in isPublicRoute() to remove duplicated path checks.
2026-04-02 20:53:49 -07:00
Frédéric Guillot 4e8a0e0639 fix(ui): remove sensitive values from log messages
Stop logging CSRF tokens, OAuth2 state values, and session cookie
values to avoid leaking secrets into application logs.
2026-04-02 20:36:01 -07:00
Frédéric Guillot 96989e2a6f fix(ui): render edit template on category update validation error 2026-04-02 20:26:59 -07:00
Frédéric Guillot 4f504499fa fix(ui): redirect to category feeds page after marking feed as read
When marking a feed as read from the category feeds page
(/category/n/feeds), the redirect now returns to the same category
feeds page instead of the global /feeds page.
2026-04-02 20:08:40 -07:00
dependabot[bot] 382e179e19 build(deps): bump github.com/go-webauthn/webauthn from 0.16.1 to 0.16.2
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.16.1 to 0.16.2.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.1...v0.16.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 17:08:18 -07:00
dependabot[bot] 47ced3e1f8 build(deps): bump github.com/lib/pq from 1.12.1 to 1.12.2
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.12.1 to 1.12.2.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.12.1...v1.12.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 17:02:02 -07:00
dependabot[bot] e2f225fde9 build(deps): bump github.com/andybalholm/brotli from 1.2.0 to 1.2.1
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.2.0 to 1.2.1.
- [Commits](https://github.com/andybalholm/brotli/compare/v1.2.0...v1.2.1)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/brotli
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 13:54:57 -07:00
dependabot[bot] 932e013590 build(deps): bump github.com/lib/pq from 1.12.0 to 1.12.1
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.12.0 to 1.12.1.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.12.0...v1.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 16:02:12 -07:00
jvoisin d5c37d7d12 perf(ui): reduce amount of sql queries to get unread entries
Every list page load now executes one fewer database round-trip, eliminating a
full 3-table JOIN (`entries` + `feeds` + `categories`) that was previously done
solely for counting. The `unread_entries.go` page haw two fewer SQL queries in
the common case.
2026-03-28 21:11:34 -07:00
Frédéric Guillot b160f92ff8 refactor(config): move cross-field validation into config parser
Move option combination checks from cli.go into an exported
Validate() method on configOptions, called after both config file
and environment variable parsing complete. Add new checks for
TLS, metrics auth, database pool, and scheduler interval consistency.
2026-03-27 18:14:58 -07:00
Frédéric Guillot 31f077cccc refactor(oauth2): select provider explicitly by OAUTH2_PROVIDER value
NewManager now dispatches on the configured provider name instead of
inferring it from the presence of a discovery endpoint. The CLI validates
that OAUTH2_OIDC_DISCOVERY_ENDPOINT is set when the OIDC provider is
selected.
2026-03-27 17:39:10 -07:00
Frédéric Guillot 062641330a refactor(oauth2): apply Go conventions and add GoDoc comments
Remove Get prefix from Provider interface methods, rename Profile
struct to UserProfile to avoid method name collision, fix acronym
casing (authUrl → authURL), fix receiver naming, and return
interfaces from constructors instead of unexported concrete types.
2026-03-27 17:21:23 -07:00
ghose c61c20247d feat(locale): update gl_ES translation 2026-03-27 16:10:29 -07:00
jvoisin 873416f4be perf(ui): don't parse the keymap on every keypress
Every keypress triggers a loop over all registered shortcuts, and each
iteration calls `.split(" ")` on the combination string. With ~30 shortcuts
registered that's 30 `String.split()` allocations on every single keypress.
But since these results never change after `on()` is called, they can be
computed once there and then cached.
2026-03-27 15:11:21 -07:00
jvoisin f8171fe69f perf(ui): batch changes in markPageAsRead
Instead of interleaving DOM read/write, collect all the items first, then
modify them. This has a small but noticeable performance impact, as
`classList.add` might triggers a style invalidation.
2026-03-27 12:29:20 -07:00
jvoisin ba684cabfd perf(ui): add immutable to Cache-Control
The immutable response directive indicates that the response will not be
updated while it's fresh, saving an http query.

See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
2026-03-27 12:17:19 -07:00
jvoisin 9151dc5377 perf(storage): extract a query from a cursor lock
The fetchFeedCounter query should happen before the the db.Query call, so that
a single database connection can be (re)used, instead of using a second one.
2026-03-27 12:10:07 -07:00
jvoisin ca7c38a680 perf(storage): Use SKIP LOCKED in ArchiveEntries
This is already the case in cleanupRemovedEntriesNotInFeed
2026-03-27 12:02:28 -07:00
Frédéric Guillot f5bfd5a70c refactor(oauth2): make Google and OIDC providers mutually exclusive
Register either the OIDC provider when a discovery endpoint is
configured, or the Google provider otherwise, but never both.
Also scope the empty client secret warning to OIDC configuration.
2026-03-25 20:39:59 -07:00
Frédéric Guillot f6c5f2f740 fix(oauth2): scope OIDC client secret warning to OIDC configuration
The warning was firing even when only Google OAuth was configured
and no OIDC discovery endpoint was set.
2026-03-25 20:14:45 -07:00
Frédéric Guillot 2de3427dc7 refactor(oauth2): update Google OAuth endpoints to v2
Use the current v2 auth and token endpoint URLs and extract all
Google endpoint URLs into package-level constants.
2026-03-25 20:05:58 -07:00
jvoisin 21703c81f4 perf(sanitizer): Use an io.MultiReader
This avoids having to copy rawHTML.
2026-03-25 16:31:56 -07:00
jvoisin b79f22a63c perf(sanitizer): html.Parse already lowercases tag names 2026-03-25 16:26:44 -07:00
dependabot[bot] 59bd092a02 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.10 to 2.24.11
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.10 to 2.24.11.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.10...v2.24.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 16:26:06 -07:00
Frédéric Guillot 1d4ca6a6bc fix(oauth2): check HTTP status from Google userinfo endpoint 2026-03-24 21:37:29 -07:00
Frédéric Guillot b4c9719000 fix(oauth2): clear state and code verifier from session after use
Prevents replay of the one-time-use PKCE code verifier and CSRF state
values by clearing them from the app session immediately after the
OAuth2 callback consumes them.
2026-03-24 21:28:05 -07:00
Frédéric Guillot 5c9edf3c1b fix(oauth2): verify OIDC ID token signature before trusting claims
The ID token from the token exchange was not being validated. Now the
provider's JWKS keys are used to verify the JWT signature, issuer,
audience, and expiry. The verified subject is also cross-checked against
the UserInfo response to detect mismatches.
2026-03-24 21:13:58 -07:00
Frédéric Guillot 2b21269900 fix(oauth2): reject link overwrite when user already has a linked identity
If a logged-in user already has an OAuth2 identity linked, reject
callbacks that would replace it with a different identity.
2026-03-24 20:40:57 -07:00
dependabot[bot] d57774df00 build(deps): bump golang.org/x/image from 0.37.0 to 0.38.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/image/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 16:41:22 -07:00
Frédéric Guillot 07a9685ae5 ci(workflows): add consistent repository_owner guards
Add missing owner checks to prevent forks from running scheduled
and publish jobs unnecessarily.
2026-03-23 20:24:02 -07:00
Frédéric Guillot cc06154d85 fix(timezone): compare locations by name instead of pointer
Pointer comparison would miss equivalent locations loaded
independently, causing an unnecessary t.In() call.
2026-03-23 20:11:29 -07:00
Frédéric Guillot fcde20903c refactor(timezone): simplify AvailableTimezones and fix test typos
Replace hand-rolled iterator with slices.Values and correct
"an it's not" to "and it's not" in test error messages.
2026-03-23 20:03:12 -07:00
Frédéric Guillot 570227c263 refactor(validator): reuse ValidateDirection in user modification 2026-03-23 19:54:57 -07:00
Frédéric Guillot 2572809998 refactor(worker): add graceful shutdown to worker pool
Close the job channel and use a WaitGroup so workers drain in-flight
jobs before the process exits. Also fix godoc comments.
2026-03-23 19:46:24 -07:00
Frédéric Guillot 16ff071365 feat(sanitizer): allow iframes from framatube.org 2026-03-23 19:29:35 -07:00
Frédéric Guillot 293209098a fix(sanitizer): strip inner elements of blocked iframes
Previously, when an iframe was blocked by the allowlist check, its child
elements were still rendered as content. Now blocked iframes discard
their children, matching the behavior of allowed iframes.
2026-03-23 19:14:56 -07:00
Frédéric Guillot 76452fab99 refactor(storage): return errors from count functions used by metrics
Return errors instead of zero values or nil from CountUsers,
CountAllFeeds, CountAllFeedsWithErrors, and CountAllEntries so the
metric collector can log failures and preserve previous gauge values.
2026-03-22 21:07:29 -07:00
Frédéric Guillot 8c947e639b refactor(metric): replace hardcoded status labels with constants
Export StatusSuccess and StatusError constants from the metric package
to prevent typos and improve grep-ability across call sites.
2026-03-22 20:41:17 -07:00
Frédéric Guillot 96897a8425 refactor(metric): support graceful shutdown of metrics collector
Replace time.Tick with time.NewTicker and accept a context.Context
so the polling goroutine can be stopped cleanly during shutdown.
2026-03-22 20:30:02 -07:00
Frédéric Guillot fa9ab92b40 docs(googlereader): update README to reflect HMAC-SHA256 change 2026-03-21 17:13:19 -07:00
Frédéric Guillot 6ea078c1cb fix(googlereader): use HMAC-SHA256 instead of HMAC-SHA1 for auth tokens 2026-03-21 17:13:19 -07:00
Frédéric Guillot 3105e1e55b fix(googlereader): use constant-time comparison for auth token validation 2026-03-21 16:51:28 -07:00
Frédéric Guillot 9c28982f7c refactor(server): extract listen target resolution into testable functions
Restructure the web server startup to separate listen target
determination from server lifecycle management.
2026-03-21 11:58:00 -07:00
Frédéric Guillot 0162c467f2 refactor(ui): use consistent cache-busted URL pattern for all static assets
Move JS and CSS routes to /js/{checksum}/{filename} and
/stylesheets/{checksum}/{filename}, matching the icon route pattern.
Use full filenames (e.g. "app.js", "light_serif.css") as bundle map
keys so handlers can use PathValue directly without suffix stripping.
2026-03-21 11:09:20 -07:00
Frédéric Guillot d33544e26a feat(ui): add cache busting for static icon assets
Embed content checksums in icon URLs (e.g. /icon/<checksum>/sprite.svg)
so browsers fetch updated assets on upgrade instead of serving stale
cached versions. This matches the existing pattern used for JS and CSS
bundles.

Closes #3728
2026-03-21 10:34:52 -07:00
Frédéric Guillot f03285883b refactor: remove dependency on gorilla/mux 2026-03-20 14:55:20 -07:00
Frédéric Guillot cd3ea68024 fix(api): CORS preflight requests should be a 204 response 2026-03-18 20:25:37 -07:00
Frédéric Guillot 925b05f912 fix(response): 204 responses should not include the Content-Type header 2026-03-18 20:12:31 -07:00
Frédéric Guillot cc528b640b refactor(api): remove dependency on gorilla/mux 2026-03-18 19:56:16 -07:00
Frédéric Guillot 1e42cec0ee feat(request): support ServeMux PathValue route params 2026-03-18 17:49:38 -07:00
Frédéric Guillot 6276d7e69b refactor(api): rename API handlers for consistency 2026-03-18 17:20:42 -07:00
Frédéric Guillot 50947318e1 refactor(mediaproxy): remove dependency on gorilla/mux in unit tests 2026-03-18 16:20:38 -07:00
dependabot[bot] fa7184e41b build(deps): bump github.com/lib/pq from 1.11.2 to 1.12.0
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.11.2 to 1.12.0.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.11.2...v1.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 15:58:02 -07:00
Frédéric Guillot b1713dc626 refactor(mediaproxy): remove dependency on gorilla/mux 2026-03-18 15:56:48 -07:00
Frédéric Guillot a392b0d53f refactor(googlereader): remove dependency on gorilla/mux 2026-03-18 11:31:16 -07:00
Frédéric Guillot 6093819c9c docs(googlereader): add API documentation 2026-03-18 09:59:55 -07:00
jvoisin 4413c80a2a perf(googlereader): preallocate some slices 2026-03-18 09:59:38 -07:00
Frédéric Guillot f50c5dda7b refactor(fever): remove dependency on gorilla/mux 2026-03-17 21:47:09 -07:00
jvoisin b965e45684 perf(mediaproxy): convert to []byte in ProxifyAbsoluteURL only once
There is no need to call `[]byte(mediaURL)` twice. It might make a minor
difference performance-wise on images-heavy feed items with long media URL,
like autogenerated CDN ones.
2026-03-17 20:43:31 -07:00
jvoisin 9660e8a84d perf(route): preallocate parameters in route.Path
This function is called for every URL in templates, so reducing heap usage
is interesting.
2026-03-17 20:42:40 -07:00
Frédéric Guillot e9edda8ef6 refactor(response): add Text response helper 2026-03-17 11:21:40 -07:00
Frédéric Guillot b68d1410e4 feat(response): support weak ETag comparison for If-None-Match header 2026-03-17 10:36:22 -07:00
Frédéric Guillot ab0ea2da43 fix(response): add vary header for encoding negotiation
Set `Vary: Accept-Encoding` when large responses
are eligible for compression so caches keep encoded and
identity variants separate.
2026-03-17 09:37:51 -07:00
jvoisin 74b37e4b78 refactor(ui): remove superfluous spaces in sprite.svg 2026-03-16 16:27:54 -07:00
dependabot[bot] 70f14e1413 build(deps): bump github.com/PuerkitoBio/goquery from 1.11.0 to 1.12.0
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.11.0...v1.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 16:10:42 -07:00
Frédéric Guillot 5c414f586b refactor(http): use explicit response body setters 2026-03-15 22:10:47 -07:00
Frédéric Guillot b18ecc0a78 refactor(http): move response format helpers into parent package 2026-03-15 21:55:45 -07:00
Frédéric Guillot 30c9ba9f18 fix(mediaproxy): ignore unsupported proxy targets 2026-03-15 21:20:05 -07:00
Frédéric Guillot 246b706945 fix: enforce formatting in make lint and remove stale phony targets
The lint target does not actually enforce formatting. It prints diff
but exits successfully when files are badly formatted.
2026-03-15 21:16:33 -07:00
Frédéric Guillot de61a3c64c fix(mediaproxy): match MIME types case-insensitively 2026-03-15 21:10:43 -07:00
Frédéric Guillot a89852a331 fix(locale): apply Arabic plural rules to ar_SA
The plural-form switch handled Arabic under ar_AR, but the supported
locale and translation file use ar_SA.

That mismatch made Arabic requests fall back to the default two-form
rule, so plural translations selected the wrong string at runtime.
2026-03-15 20:53:45 -07:00
Frédéric Guillot 7516bdfcaf fix(locale): guard nil wrapped errors in Translate 2026-03-15 20:37:57 -07:00
Frédéric Guillot 7e5219e5ac fix(icon): reject oversized favicons 2026-03-15 20:20:11 -07:00
Frédéric Guillot 2d3a9aebb4 fix(template): avoid DoS in truncate() when processing untrusted input
Avoid scanning and allocating entire untrusted feed titles during
template rendering just to truncate them. The previous
implementation could turn large remote titles into unnecessary CPU
and memory pressure on each page render.

Iterating only until the requested rune boundary preserves
Unicode-safe truncation while keeping the work proportional to the
visible output instead of the full input.
2026-03-15 19:58:23 -07:00
Frédéric Guillot 7d0ed5d252 fix(cli): bypass logger setup for info and version flags
Return early for --info and --version.
Avoid failing these read-only commands when log file initialization is broken.
2026-03-15 19:25:19 -07:00
Frédéric Guillot 9cd7629615 fix(cli): handle terminal credential input errors 2026-03-15 19:15:37 -07:00
Frédéric Guillot 4cd9dd6af7 refactor(validator): replace IsValidURL with urllib.IsAbsoluteURL 2026-03-15 17:31:42 -07:00
Frédéric Guillot c2cbf5370d fix(validator): allow clearing user filter rules on update 2026-03-15 17:05:37 -07:00
Frédéric Guillot c166f80b58 fix: block RFC 6598 shared address space as non-public
Treat 100.64.0.0/10 as non-public in urllib.IsNonPublicIP.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This should save some time when trying to find a feed, as this function is
called a handful of times on various pages.
2026-02-13 16:35:50 -08:00
jvoisin 6838f3a6e9 perf(sanitizer): use WriteByte for single characters instead of WriteString 2026-02-13 16:30:24 -08:00
jvoisin 8483f06595 perf(sanitizer): refactor hasRequiredAttributes
Instead of operating on a slices that is built/garbage-collected on every HTML
tag, use a struct keeping track of the mandatory attributes. This commit also
replaces two `continue` with `return`, as there is no point to continue
analysing the tag if the conditions surrounding the continue aren't met.
2026-02-13 16:30:24 -08:00
jvoisin da15700205 perf(sanitizer): inline getExtraAttributes
This saves allocating a temporary slice only to have it spread.
2026-02-13 16:30:24 -08:00
jvoisin cd6236b1cd perf(sanitize): Use strings.Builder instead of manually concatenating 2026-02-13 16:30:24 -08:00
Serpicroon 7b07b8256b feat(feed): add ignore_entry_updates option to feeds
It introduces a new configuration option `ignore_entry_updates` for feeds, allowing users to skip updating existing entries during scheduled polling.
This is useful when external services (e.g., AI summarizers) modify entry content and users want to preserve those modifications across feed syncs.
2026-02-13 16:25:15 -08:00
410 changed files with 17710 additions and 9274 deletions
+20 -19
View File
@@ -3,29 +3,30 @@ updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
groups:
gomod:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
directories:
- "/packaging/docker/alpine"
- "/packaging/docker/distroless"
- "/packaging/debian"
- "/packaging/rpm"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
interval: "monthly"
groups:
docker:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "monthly"
groups:
github-actions:
patterns:
- "*"
+12 -3
View File
@@ -6,6 +6,14 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- '.github/workflows/build_binaries.yml'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- '**.go'
jobs:
build:
name: Build
@@ -13,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Golang
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
check-latest: true
@@ -24,7 +32,8 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v6
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries
path: miniflux-*
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Mirror to Codeberg
+5 -5
View File
@@ -38,22 +38,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@v6
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{ matrix.language }}"
+14 -12
View File
@@ -11,19 +11,21 @@ on:
branches: [ main ]
paths:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
- '.github/workflows/debian_packages.yml'
jobs:
test-packages:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -38,13 +40,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -53,24 +55,24 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.deb"
if-no-files-found: error
retention-days: 3
publish-packages:
if: github.event_name == 'push'
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
+16 -17
View File
@@ -9,6 +9,7 @@ on:
branches: [ main ]
paths:
- 'packaging/docker/**'
- '.github/workflows/docker.yml'
jobs:
docker-images:
name: Docker Images
@@ -18,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -37,7 +38,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -51,50 +52,48 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
if: ${{ github.event_name != 'pull_request' }}
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' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
if: ${{ github.event_name != 'pull_request' }}
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@v6
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v6
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- 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@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@v9
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,11 +38,11 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
- name: Validate PR commits
+7 -6
View File
@@ -14,11 +14,12 @@ on:
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
@@ -30,24 +31,24 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.rpm"
if-no-files-found: error
retention-days: 3
publish-package:
if: github.event_name == 'push'
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
+27
View File
@@ -0,0 +1,27 @@
name: Close Stale Pull Requests
permissions: read-all
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-pr-stale: 60
days-before-pr-close: 14
stale-pr-label: stale
stale-pr-message: >
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs within 14 days.
close-pr-message: >
This pull request has been automatically closed due to inactivity.
Please feel free to reopen it if you would like to continue working on it.
days-before-issue-stale: -1
days-before-issue-close: -1
+5 -5
View File
@@ -17,9 +17,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
@@ -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,9 +44,9 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Install Postgres client
+2 -2
View File
@@ -35,7 +35,7 @@ When reporting bugs:
### Requirements
- **Git**
- **Go >= 1.24**
- **Go >= 1.26**
- **PostgreSQL**
### Getting Started
@@ -103,7 +103,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
+11 -5
View File
@@ -16,12 +16,11 @@ export PGPASSWORD := postgres
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-x86 \
linux-riscv64 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
openbsd-amd64 \
netbsd-amd64 \
build \
run \
clean \
@@ -63,6 +62,10 @@ linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-riscv64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
@@ -79,7 +82,7 @@ openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 linux-riscv64 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@@ -100,7 +103,7 @@ test:
lint:
go vet ./...
gofmt -d -e .
test -z "$$(gofmt -l .)"
golangci-lint run
integration-test:
@@ -113,6 +116,8 @@ integration-test:
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
@@ -135,7 +140,7 @@ docker-image-distroless:
docker-images:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
--file packaging/docker/alpine/Dockerfile \
--tag $(DOCKER_IMAGE):$(VERSION) \
--push .
@@ -162,3 +167,4 @@ debian-packages: clean
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7
$(MAKE) debian DOCKER_PLATFORM=riscv64
+1 -1
View File
@@ -103,7 +103,7 @@ Features
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM and RISC-V architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
+76 -3
View File
@@ -22,6 +22,8 @@ type Client struct {
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
//
//go:fix inline
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
}
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
Title: new(title),
})
if err != nil {
return nil, err
@@ -886,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()
@@ -979,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()
@@ -1077,14 +1146,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
return &result, nil
}
// FlushHistory changes all entries with the status "read" to "removed".
// FlushHistory deletes all entries with the status "read".
func (c *Client) FlushHistory() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext changes all entries with the status "read" to "removed".
// FlushHistoryContext deletes all entries with the status "read".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
@@ -1229,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))
}
}
+38 -7
View File
@@ -10,9 +10,8 @@ import (
// Entry statuses.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
EntryStatusUnread = "unread"
EntryStatusRead = "read"
)
// User represents a user in the system.
@@ -75,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"`
@@ -149,12 +146,15 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at,omitempty"`
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"`
@@ -166,6 +166,7 @@ type Feed struct {
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
@@ -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.
@@ -185,7 +194,9 @@ type FeedCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -206,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"`
@@ -214,12 +226,14 @@ type FeedModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
NoMediaPlayer *bool `json:"no_media_player"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
@@ -318,6 +332,7 @@ type Filter struct {
CategoryID int64
FeedID int64
Statuses []string
Tags []string
GloballyVisible bool
}
@@ -327,6 +342,20 @@ type EntryResultSet struct {
Entries Entries `json:"entries"`
}
// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
type EntryIDsFilter struct {
Limit int
Offset int
Starred *bool
Status string
}
// EntryIDsResultSet represents the response when fetching entry ID lists.
type EntryIDsResultSet struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
// VersionResponse represents the version and the build information of the Miniflux instance.
type VersionResponse struct {
Version string `json:"version"`
@@ -357,6 +386,8 @@ type APIKeyCreationRequest struct {
}
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
//
//go:fix inline
func SetOptionalField[T any](value T) *T {
return &value
return new(value)
}
+1
View File
@@ -21,6 +21,7 @@ services:
db:
image: postgres:latest
container_name: postgres
restart: always
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
+26 -26
View File
@@ -1,50 +1,50 @@
module miniflux.app/v2
// +heroku goVersion go1.24
// When changing version here don't forget to also upgrade CONTRIBUTING.md
// +heroku goVersion go1.26
go 1.26.0
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.15.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
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.8
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/term v0.40.0
github.com/tdewolff/minify/v2 v2.24.13
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.44.0
golang.org/x/text v0.38.0
)
require (
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
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.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.24.0
toolchain go1.24.1
+48 -43
View File
@@ -1,39 +1,39 @@
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
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=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
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.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=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -42,10 +42,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -60,12 +62,15 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -83,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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/crypto v0.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=
@@ -101,10 +106,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -123,8 +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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -134,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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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=
@@ -145,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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
+60 -78
View File
@@ -5,92 +5,74 @@ package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"runtime"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/version"
"miniflux.app/v2/internal/worker"
"github.com/gorilla/mux"
)
type handler struct {
store *storage.Storage
pool *worker.Pool
router *mux.Router
store *storage.Storage
pool *worker.Pool
}
// Serve declares API routes for the application.
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
handler := &handler{store, pool, router}
sr := router.PathPrefix("/v1").Subrouter()
// NewHandler returns an http.Handler that handles API v1 calls.
// The returned handler expects the base path to be stripped from the request URL.
func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
handler := &handler{store: store, pool: pool}
middleware := newMiddleware(store)
sr.Use(middleware.handleCORS)
sr.Use(middleware.apiKeyAuth)
sr.Use(middleware.basicAuth)
sr.Methods(http.MethodOptions)
sr.HandleFunc("/users", handler.createUser).Methods(http.MethodPost)
sr.HandleFunc("/users", handler.users).Methods(http.MethodGet)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods(http.MethodGet)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods(http.MethodPut)
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods(http.MethodDelete)
sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsRead).Methods(http.MethodPut)
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods(http.MethodGet)
sr.HandleFunc("/me", handler.currentUser).Methods(http.MethodGet)
sr.HandleFunc("/categories", handler.createCategory).Methods(http.MethodPost)
sr.HandleFunc("/categories", handler.getCategories).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete)
sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}/feeds", handler.getCategoryFeeds).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}/refresh", handler.refreshCategory).Methods(http.MethodPut)
sr.HandleFunc("/categories/{categoryID}/entries", handler.getCategoryEntries).Methods(http.MethodGet)
sr.HandleFunc("/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntry).Methods(http.MethodGet)
sr.HandleFunc("/discover", handler.discoverSubscriptions).Methods(http.MethodPost)
sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost)
sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet)
sr.HandleFunc("/feeds/counters", handler.fetchCounters).Methods(http.MethodGet)
sr.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods(http.MethodPut)
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods(http.MethodDelete)
sr.HandleFunc("/feeds/{feedID}/icon", handler.getIconByFeedID).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsRead).Methods(http.MethodPut)
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
}
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, &versionResponse{
Version: version.Version,
Commit: version.Commit,
BuildDate: version.BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Arch: runtime.GOARCH,
OS: runtime.GOOS,
})
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/users", handler.createUserHandler)
mux.HandleFunc("GET /v1/users", handler.usersHandler)
mux.HandleFunc("GET /v1/users/{identifier}", handler.dispatchUserLookupHandler)
mux.HandleFunc("PUT /v1/users/{userID}", handler.updateUserHandler)
mux.HandleFunc("DELETE /v1/users/{userID}", handler.removeUserHandler)
mux.HandleFunc("PUT /v1/users/{userID}/mark-all-as-read", handler.markUserAsReadHandler)
mux.HandleFunc("GET /v1/me", handler.currentUserHandler)
mux.HandleFunc("POST /v1/categories", handler.createCategoryHandler)
mux.HandleFunc("GET /v1/categories", handler.getCategoriesHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}", handler.updateCategoryHandler)
mux.HandleFunc("DELETE /v1/categories/{categoryID}", handler.removeCategoryHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}/refresh", handler.refreshCategoryHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/entries", handler.getCategoryEntriesHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler)
mux.HandleFunc("POST /v1/discover", handler.discoverSubscriptionsHandler)
mux.HandleFunc("POST /v1/feeds", handler.createFeedHandler)
mux.HandleFunc("GET /v1/feeds", handler.getFeedsHandler)
mux.HandleFunc("GET /v1/feeds/counters", handler.fetchCountersHandler)
mux.HandleFunc("PUT /v1/feeds/refresh", handler.refreshAllFeedsHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}/refresh", handler.refreshFeedHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}", handler.getFeedHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}", handler.updateFeedHandler)
mux.HandleFunc("DELETE /v1/feeds/{feedID}", handler.removeFeedHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/icon", handler.getIconByFeedIDHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler)
mux.HandleFunc("GET /v1/export", handler.exportFeedsHandler)
mux.HandleFunc("POST /v1/import", handler.importFeedsHandler)
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.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)
mux.HandleFunc("PUT /v1/entries/{entryID}/star", handler.toggleStarredHandler)
mux.HandleFunc("POST /v1/entries/{entryID}/save", handler.saveEntryHandler)
mux.HandleFunc("GET /v1/entries/{entryID}/fetch-content", handler.fetchContentHandler)
mux.HandleFunc("PUT /v1/flush-history", handler.flushHistoryHandler)
mux.HandleFunc("DELETE /v1/flush-history", handler.flushHistoryHandler)
mux.HandleFunc("GET /v1/icons/{iconID}", handler.getIconByIconIDHandler)
mux.HandleFunc("GET /v1/enclosures/{enclosureID}", handler.getEnclosureByIDHandler)
mux.HandleFunc("PUT /v1/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler)
mux.HandleFunc("GET /v1/integrations/status", handler.getIntegrationsStatusHandler)
mux.HandleFunc("GET /v1/version", handler.versionHandler)
mux.HandleFunc("POST /v1/api-keys", handler.createAPIKeyHandler)
mux.HandleFunc("GET /v1/api-keys", handler.getAPIKeysHandler)
mux.HandleFunc("DELETE /v1/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler)
return middleware.withCORSHeaders(middleware.validateAPIKeyAuth(middleware.validateBasicAuth(mux)))
}
+312 -73
View File
@@ -580,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -609,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
ExternalFontHosts: new(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -638,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
ExternalFontHosts: new("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
@@ -662,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
CustomJS: new("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -691,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("invalid_theme"),
Theme: new("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -721,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
@@ -1090,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
Title: new("new title"),
})
if err != nil {
t.Fatal(err)
@@ -1109,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
HideGlobally: new(true),
})
if err != nil {
t.Fatal(err)
@@ -1128,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
HideGlobally: new(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
@@ -1612,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
FeedURL: new("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
@@ -1653,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
FeedURL: new(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -1685,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: miniflux.SetOptionalField(int64(123456789)),
CategoryID: new(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -2413,64 +2413,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
}
func TestCannotGetRemovedEntries(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)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if feedEntries.Total == 0 {
t.Fatalf(`Expected at least one entry, got none`)
}
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if updatedFeedEntries.Total != feedEntries.Total-1 {
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2689,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() {
@@ -2718,8 +2957,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: miniflux.SetOptionalField("New title"),
Content: miniflux.SetOptionalField("New content"),
Title: new("New title"),
Content: new("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
@@ -9,56 +9,60 @@ import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
func (h *handler) createAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, apiKey)
response.JSONCreated(w, r, apiKey)
}
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
func (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, apiKeys)
response.JSON(w, r, apiKeys)
}
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
func (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if apiKeyID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid API key ID"))
return
}
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"miniflux.app/v2/internal/version"
)
func TestNewHandlerHandlesOptionsRequests(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodOptions, "/v1/users", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusNoContent {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
t.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "X-Auth-Token, Authorization, Content-Type, Accept" {
t.Fatalf(`Unexpected Access-Control-Allow-Headers header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Max-Age"); got != "3600" {
t.Fatalf(`Unexpected Access-Control-Max-Age header, got %q`, got)
}
}
func TestVersionHandler(t *testing.T) {
h := &handler{}
r := httptest.NewRequest(http.MethodGet, "/v1/version", nil)
w := httptest.NewRecorder()
h.versionHandler(w, r)
if got := w.Code; got != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
}
if got := w.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf(`Unexpected Content-Type header, got %q`, got)
}
var responseBody versionResponse
if err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {
t.Fatalf("Unexpected JSON decoding error: %v", err)
}
if responseBody.Version != version.Version {
t.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)
}
if responseBody.Commit != version.Commit {
t.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)
}
if responseBody.BuildDate != version.BuildDate {
t.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)
}
if responseBody.GoVersion != runtime.Version() {
t.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())
}
if responseBody.Compiler != runtime.Compiler {
t.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)
}
if responseBody.Arch != runtime.GOARCH {
t.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)
}
if responseBody.OS != runtime.GOOS {
t.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)
}
}
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
prefix string
path string
}{
{name: "empty base path", prefix: "", path: "/v1/users"},
{name: "non empty base path", prefix: "/base", path: "/base/v1/users"},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
handler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))
r := httptest.NewRequest(http.MethodOptions, scenario.path, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusNoContent {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
}
})
}
}
@@ -5,149 +5,179 @@ package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
func (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var categoryCreationRequest model.CategoryCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, category)
response.JSONCreated(w, r, category)
}
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
var categoryModificationRequest model.CategoryModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, category)
response.JSONCreated(w, r, category)
}
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
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" {
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
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))
}
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, categories)
response.JSON(w, r, categories)
}
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
if !h.store.CategoryIDExists(userID, categoryID) {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -160,5 +190,5 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
go h.pool.Push(jobs)
json.NoContent(w, r)
response.NoContent(w, r)
}
@@ -5,76 +5,73 @@ package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
if enclosureID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if enclosure == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
json.NotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, enclosure)
response.JSON(w, r, enclosure)
}
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
if enclosureID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
return
}
var enclosureUpdateRequest model.EnclosureUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if enclosure == nil {
json.NotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
-513
View File
@@ -1,513 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
entry, err := b.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, entry)
}
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
h.findEntries(w, r, feedID, 0)
}
func (h *handler) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
h.findEntries(w, r, 0, categoryID)
}
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
h.findEntries(w, r, 0, 0)
}
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
statuses := request.QueryStringParamList(r, "status")
for _, status := range statuses {
if err := validator.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, r, err)
return
}
}
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := validator.ValidateEntryOrder(order); err != nil {
json.BadRequest(w, r, err)
return
}
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := validator.ValidateDirection(direction); err != nil {
json.BadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := validator.ValidateRange(offset, limit); err != nil {
json.BadRequest(w, r, err)
return
}
userID := request.UserID(r)
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
json.BadRequest(w, r, errors.New("invalid category ID"))
return
}
feedID = request.QueryInt64Param(r, "feed_id", feedID)
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
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.WithoutStatus(model.EntryStatusRemoved)
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
return
}
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
if !h.store.HasSaveEntry(request.UserID(r)) {
json.BadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
settings, err := h.store.Integration(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
go integration.SendEntry(entry, settings)
json.Accepted(w, r)
}
func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
var entryUpdateRequest model.EntryUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
json.BadRequest(w, r, err)
return
}
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, entry)
}
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("feed does not exist"))
return
}
var importRequest entryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if importRequest.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}
if importRequest.Status == "" {
importRequest.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
json.BadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = importRequest.URL
entry.CommentsURL = importRequest.CommentsURL
entry.Author = importRequest.Author
entry.Tags = importRequest.Tags
if importRequest.PublishedAt > 0 {
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if importRequest.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = importRequest.Title
}
hashInput := importRequest.ExternalID
if hashInput == "" {
hashInput = importRequest.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if importRequest.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if err != nil {
json.ServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = importRequest.Status
if importRequest.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
json.Created(w, r, entryIDResponse{ID: entry.ID})
} else {
json.OK(w, r, entryIDResponse{ID: entry.ID})
}
}
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
if err != nil {
json.ServerError(w, r, err)
return
}
if feed == nil {
json.NotFound(w, r)
return
}
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err)
return
}
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
}
json.OK(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
json.Accepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
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)
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
}
}
+614
View File
@@ -0,0 +1,614 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
entry, err := b.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)
entry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, entry)
}
func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithFeedID(feedID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithCategoryID(categoryID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
h.findEntries(w, r, feedID, 0)
}
func (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
h.findEntries(w, r, 0, categoryID)
}
func (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {
h.findEntries(w, r, 0, 0)
}
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
statuses := request.QueryStringParamList(r, "status")
for _, status := range statuses {
if err := validator.ValidateEntryStatus(status); err != nil {
response.JSONBadRequest(w, r, err)
return
}
}
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := validator.ValidateEntryOrder(order); err != nil {
response.JSONBadRequest(w, r, err)
return
}
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := validator.ValidateDirection(direction); err != nil {
response.JSONBadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := validator.ValidateRange(offset, limit); err != nil {
response.JSONBadRequest(w, r, err)
return
}
userID := request.UserID(r)
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
feedID = request.QueryInt64Param(r, "feed_id", feedID)
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
tags := request.QueryStringParamList(r, "tags")
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 = builder.WithGloballyVisible()
}
}
builder = configureFilters(builder, r)
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
response.JSONServerError(w, r, err)
return
}
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) 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.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(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)
}
func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
if !h.store.HasSaveEntry(request.UserID(r)) {
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
settings, err := h.store.Integration(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
go integration.SendEntry(entry, settings)
response.JSONAccepted(w, r)
}
func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
var entryUpdateRequest model.EntryUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
loggedUserID := request.UserID(r)
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, entry)
}
func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
response.JSONBadRequest(w, r, errors.New("feed does not exist"))
return
}
var importRequest entryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if importRequest.URL == "" {
response.JSONBadRequest(w, r, errors.New("url is required"))
return
}
if importRequest.Status == "" {
importRequest.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
response.JSONBadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = importRequest.URL
entry.CommentsURL = importRequest.CommentsURL
entry.Author = importRequest.Author
entry.Tags = importRequest.Tags
if importRequest.PublishedAt > 0 {
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if importRequest.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = importRequest.Title
}
hashInput := importRequest.ExternalID
if hashInput == "" {
hashInput = importRequest.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if importRequest.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if errors.Is(err, storage.ErrEntryTombstoned) {
response.JSONBadRequest(w, r, err)
return
}
if err != nil {
response.JSONServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
entry.Status = importRequest.Status
if importRequest.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
response.JSONServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
response.JSONCreated(w, r, entryIDResponse{ID: entry.ID})
} else {
response.JSON(w, r, entryIDResponse{ID: entry.ID})
}
}
func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
WithFeedID(entry.FeedID).
GetFeed()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if feed == nil {
response.JSONNotFound(w, r)
return
}
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
response.JSONServerError(w, r, err)
return
}
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
response.JSONServerError(w, r, err)
return
}
}
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) *storage.EntryQueryBuilder {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder = builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder = builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
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 = builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); 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
}
@@ -5,24 +5,25 @@ package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
func (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var feedCreationRequest model.FeedCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
@@ -30,57 +31,60 @@ func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
if feedCreationRequest.CategoryID == 0 {
category, err := h.store.FirstCategory(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
feedCreationRequest.CategoryID = category.ID
}
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
feed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
response.JSONServerError(w, r, localizedError.Error())
return
}
json.Created(w, r, &feedCreationResponse{FeedID: feed.ID})
response.JSONCreated(w, r, &feedCreationResponse{FeedID: feed.ID})
}
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
localizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
response.JSONServerError(w, r, localizedError.Error())
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) refreshAllFeeds(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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -92,141 +96,164 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
go h.pool.Push(jobs)
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
var feedModificationRequest model.FeedModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, r)
response.JSONServerError(w, r, err)
return
}
if originalFeed == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
feedModificationRequest.Patch(originalFeed)
originalFeed.ResetErrorCounter()
if err := h.store.UpdateFeed(originalFeed); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
originalFeed, err = h.store.FeedByID(userID, feedID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, originalFeed)
response.JSONCreated(w, r, originalFeed)
}
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) getCategoryFeeds(w http.ResponseWriter, r *http.Request) {
func (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, feeds)
response.JSON(w, r, feeds)
}
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
func (h *handler) getFeedsHandler(w http.ResponseWriter, r *http.Request) {
feeds, err := h.store.Feeds(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, feeds)
response.JSON(w, r, feeds)
}
func (h *handler) fetchCounters(w http.ResponseWriter, r *http.Request) {
func (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {
counters, err := h.store.FetchCounters(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, counters)
response.JSON(w, r, counters)
}
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
feed, err := h.store.FeedByID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if feed == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
json.OK(w, r, feed)
response.JSON(w, r, feed)
}
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
func (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
if err := h.store.RemoveFeed(userID, feedID); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
@@ -4,48 +4,57 @@
package api // import "miniflux.app/v2/internal/api"
import (
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
)
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if icon == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
json.OK(w, r, &feedIconResponse{
response.JSON(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
})
}
func (h *handler) getIconByIconID(w http.ResponseWriter, r *http.Request) {
func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {
iconID := request.RouteInt64Param(r, "iconID")
if iconID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid icon ID"))
return
}
icon, err := h.store.IconByID(iconID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if icon == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
json.OK(w, r, &feedIconResponse{
response.JSON(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
@@ -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"`
+12 -12
View File
@@ -9,7 +9,7 @@ import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/storage"
)
@@ -20,21 +20,21 @@ type middleware struct {
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) handleCORS(next http.Handler) http.Handler {
func (m *middleware) withCORSHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, Authorization, Content-Type, Accept")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Max-Age", "3600")
w.WriteHeader(http.StatusOK)
response.NoContent(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
func (m *middleware) validateAPIKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
token := r.Header.Get("X-Auth-Token")
@@ -51,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
user, err := m.store.UserByAPIKey(token)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -62,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
response.JSONUnauthorized(w, r)
return
}
@@ -87,7 +87,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
})
}
func (m *middleware) basicAuth(next http.Handler) http.Handler {
func (m *middleware) validateBasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if request.IsAuthenticated(r) {
next.ServeHTTP(w, r)
@@ -105,7 +105,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
response.JSONUnauthorized(w, r)
return
}
@@ -116,7 +116,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
response.JSONUnauthorized(w, r)
return
}
@@ -128,13 +128,13 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
response.JSONUnauthorized(w, r)
return
}
user, err := m.store.UserByUsername(username)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -146,7 +146,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
response.JSONUnauthorized(w, r)
return
}
@@ -7,30 +7,29 @@ import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response/xml"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/reader/opml"
)
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
func (h *handler) exportFeedsHandler(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
opmlExport, err := opmlHandler.Export(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
xml.OK(w, r, opmlExport)
response.XML(w, r, opmlExport)
}
func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
func (h *handler) importFeedsHandler(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
err := opmlHandler.Import(request.UserID(r), r.Body)
defer r.Body.Close()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
response.JSONCreated(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
}
@@ -9,7 +9,7 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
@@ -17,15 +17,15 @@ import (
"miniflux.app/v2/internal/validator"
)
func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request) {
func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Request) {
var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
response.JSONBadRequest(w, r, validationErr.Error())
return
}
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
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,
@@ -56,14 +56,14 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
response.JSONServerError(w, r, localizedError.Error())
return
}
if len(subscriptions) == 0 {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
json.OK(w, r, subscriptions)
response.JSON(w, r, subscriptions)
}
-216
View File
@@ -1,216 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, user)
}
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
var userCreationRequest model.UserCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
user, err := h.store.CreateUser(&userCreationRequest)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, user)
}
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
var userModificationRequest model.UserModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
originalUser, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if originalUser == nil {
json.NotFound(w, r)
return
}
if !request.IsAdminUser(r) {
if originalUser.ID != request.UserID(r) {
json.Forbidden(w, r)
return
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
userModificationRequest.Patch(originalUser)
if err = h.store.UpdateUser(originalUser); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, originalUser)
}
func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID != request.UserID(r) {
json.Forbidden(w, r)
return
}
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkAllAsRead(userID); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
json.OK(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
}
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
users, err := h.store.Users()
if err != nil {
json.ServerError(w, r, err)
return
}
users.UseTimezone(request.UserTimezone(r))
json.OK(w, r, users)
}
func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, r)
return
}
user.UseTimezone(request.UserTimezone(r))
json.OK(w, r, user)
}
func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, r)
return
}
json.OK(w, r, user)
}
func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if user.ID == request.UserID(r) {
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
return
}
h.store.RemoveUserAsync(user.ID)
json.NoContent(w, r)
}
+272
View File
@@ -0,0 +1,272 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
func (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
var userCreationRequest model.UserCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
user, err := h.store.CreateUser(&userCreationRequest)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, user)
}
func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
var userModificationRequest model.UserModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
originalUser, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if originalUser == nil {
response.JSONNotFound(w, r)
return
}
if !request.IsAdminUser(r) {
if originalUser.ID != request.UserID(r) {
response.JSONForbidden(w, r)
return
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
response.JSONBadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
userModificationRequest.Patch(originalUser)
if err = h.store.UpdateUser(originalUser); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, originalUser)
}
func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
if userID != request.UserID(r) {
response.JSONForbidden(w, r)
return
}
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if err := h.store.MarkAllAsRead(userID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
response.JSON(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
}
func (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
users, err := h.store.Users()
if err != nil {
response.JSONServerError(w, r, err)
return
}
users.UseTimezone(request.UserTimezone(r))
response.JSON(w, r, users)
}
func (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {
identifier := request.RouteStringParam(r, "identifier")
userID := request.RouteInt64Param(r, "identifier")
if userID > 0 {
r.SetPathValue("userID", identifier)
h.userByIDHandler(w, r)
return
}
r.SetPathValue("username", identifier)
h.userByUsernameHandler(w, r)
}
func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
user.UseTimezone(request.UserTimezone(r))
response.JSON(w, r, user)
}
func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if user.ID == request.UserID(r) {
response.JSONBadRequest(w, r, errors.New("you cannot remove yourself"))
return
}
go func() {
if err := h.store.RemoveUser(user.ID); err != nil {
slog.Error("Unable to delete user",
slog.Int64("user_id", user.ID),
slog.Any("error", err),
)
}
}()
response.NoContent(w, r)
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"runtime"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/version"
)
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, &versionResponse{
Version: version.Version,
Commit: version.Commit,
BuildDate: version.BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Arch: runtime.GOARCH,
OS: runtime.GOOS,
})
}
+18 -4
View File
@@ -23,13 +23,27 @@ func askCredentials() (string, string) {
fmt.Print("Enter Username: ")
reader := bufio.NewReader(os.Stdin)
username, _ := reader.ReadString('\n')
username, err := reader.ReadString('\n')
if err != nil {
printfAndExit("unable to read username: %w", err)
}
fmt.Print("Enter Password: ")
state, _ := term.GetState(fd)
defer term.Restore(fd, state)
bytePassword, _ := term.ReadPassword(fd)
state, err := term.GetState(fd)
if err != nil {
printfAndExit("unable to get terminal state: %w", err)
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printfAndExit("unable to restore terminal state: %w", restoreErr)
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printfAndExit("unable to read password: %w", err)
}
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
+12 -17
View File
@@ -14,12 +14,13 @@ import (
)
func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
slog.Error("Unable to clean old web sessions", slog.Any("error", err))
} else {
slog.Info("Sessions cleanup completed",
slog.Int64("web_sessions_removed", nbWebSessions),
)
}
startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
@@ -47,17 +48,11 @@ func runCleanupTasks(store *storage.Storage) {
}
}
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
}
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
} else {
slog.Info("Clearing content from removed entries completed",
slog.Int64("removed_entries_content_cleared", contentAffected))
slog.Info("Orphan icons cleanup completed",
slog.Int64("orphan_icons_removed", nbIcons),
)
}
}
+23 -38
View File
@@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"flag"
"fmt"
"io"
@@ -92,27 +91,8 @@ func Parse() {
printErrorAndExit(err)
}
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
if oauth2Provider != "oidc" && oauth2Provider != "google" {
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
}
}
if config.Opts.DisableLocalAuth() {
switch {
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
}
}
if config.Opts.AuthProxyHeader() != "" {
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
}
if err := config.Opts.Validate(); err != nil {
printErrorAndExit(err)
}
if flagConfigDump {
@@ -120,6 +100,16 @@ func Parse() {
return
}
if flagInfo {
info()
return
}
if flagVersion {
fmt.Println(version.Version)
return
}
if flagDebugMode {
config.Opts.SetLogLevel("debug")
}
@@ -134,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()
}
@@ -148,30 +138,20 @@ func Parse() {
return
}
if flagInfo {
info()
return
}
if flagVersion {
fmt.Println(version.Version)
return
}
if config.Opts.IsDefaultDatabaseURL() {
slog.Info("The default value for DATABASE_URL is used")
}
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(
@@ -181,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()
@@ -251,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)
}
}
@@ -272,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)
}
+7 -1
View File
@@ -38,9 +38,10 @@ func startDaemon(store *storage.Storage) {
httpServers = server.StartWebServer(store, pool)
}
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
if config.Opts.HasMetricsCollector() {
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
go collector.GatherStorageMetrics()
go collector.GatherStorageMetrics(metricsCtx)
}
if systemd.HasNotifySocket() {
@@ -75,6 +76,7 @@ func startDaemon(store *storage.Storage) {
<-stop
slog.Debug("Shutting down the process")
cancelMetrics()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -92,5 +94,9 @@ func startDaemon(store *storage.Storage) {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Shutting down worker pool...")
pool.Shutdown()
slog.Debug("Worker pool shut down.")
slog.Debug("Process gracefully stopped")
}
+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()))
+15 -12
View File
@@ -219,6 +219,11 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_BILIBILI_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
@@ -293,7 +298,7 @@ func NewConfigOptions() *configOptions {
rawValue: "0",
valueType: boolType,
},
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
@@ -353,11 +358,6 @@ func NewConfigOptions() *configOptions {
rawValue: "",
valueType: urlType,
},
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
parsedDuration: 120 * time.Second,
rawValue: "120",
@@ -791,8 +791,15 @@ func (c *configOptions) HTTPS() bool {
return c.options["HTTPS"].parsedBoolValue
}
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
if c == nil {
return false
}
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) InvidiousInstance() string {
@@ -847,10 +854,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
}
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
}
+324 -33
View File
@@ -1351,27 +1351,51 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IconFetchAllowPrivateNetworks() {
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
@@ -1442,30 +1466,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyAllowPrivateNetworks() {
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1752,3 +1752,294 @@ func TestConfigMapWithRedactedSecrets(t *testing.T) {
t.Fatalf("Expected ADMIN_PASSWORD value to be redacted, got '%s'", configMap[0].Value)
}
}
func TestValidateOIDCProviderRequiresDiscoveryEndpoint(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
err := configParser.options.Validate()
if err == nil {
t.Fatal("Expected error when OIDC provider is set without discovery endpoint")
}
if err.Error() != "OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider" {
t.Fatalf("Unexpected error message: %v", err)
}
}
func TestValidateOIDCProviderWithDiscoveryEndpoint(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithoutAlternative(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when local auth is disabled without alternative")
}
}
func TestValidateDisableLocalAuthWithOAuth2ButNoUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithOAuth2AndUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
"OAUTH2_USER_CREATION=1",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithAuthProxyButNoUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"AUTH_PROXY_HEADER=X-Forwarded-User",
"AUTH_PROXY_USER_CREATION=0",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithAuthProxyAndUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"AUTH_PROXY_HEADER=X-Forwarded-User",
"AUTH_PROXY_USER_CREATION=1",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateAuthProxyRequiresTrustedNetworks(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
err := configParser.options.Validate()
if err == nil {
t.Fatal("Expected error when auth proxy header is set without trusted networks")
}
if err.Error() != "TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used" {
t.Fatalf("Unexpected error message: %v", err)
}
}
func TestValidateAuthProxyWithTrustedNetworks(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"AUTH_PROXY_HEADER=X-Forwarded-User",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateCertFileMissingKeyFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when CERT_FILE is set without KEY_FILE")
}
}
func TestValidateKeyFileMissingCertFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when KEY_FILE is set without CERT_FILE")
}
}
func TestValidateCertFileAndKeyFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"CERT_FILE=/path/to/cert.pem",
"KEY_FILE=/path/to/key.pem",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateCertDomainAndCertFileMutuallyExclusive(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"CERT_DOMAIN=example.com",
"CERT_FILE=/path/to/cert.pem",
"KEY_FILE=/path/to/key.pem",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when both CERT_DOMAIN and CERT_FILE are set")
}
}
func TestValidateCertDomainAlone(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateMetricsUsernameWithoutPassword(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"METRICS_USERNAME=admin"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when METRICS_USERNAME is set without METRICS_PASSWORD")
}
}
func TestValidateMetricsPasswordWithoutUsername(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when METRICS_PASSWORD is set without METRICS_USERNAME")
}
}
func TestValidateMetricsUsernameAndPassword(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"METRICS_USERNAME=admin",
"METRICS_PASSWORD=secret",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDatabaseMinConnsGreaterThanMaxConns(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DATABASE_MIN_CONNS=25",
"DATABASE_MAX_CONNS=10",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when DATABASE_MIN_CONNS > DATABASE_MAX_CONNS")
}
}
func TestValidateDatabaseMinConnsEqualToMaxConns(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DATABASE_MIN_CONNS=10",
"DATABASE_MAX_CONNS=10",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateSchedulerRoundRobinMinGreaterThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=1440",
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when SCHEDULER_ROUND_ROBIN_MIN_INTERVAL > SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
}
}
func TestValidateSchedulerRoundRobinMinLessThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=60",
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=1440",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateSchedulerEntryFrequencyMinGreaterThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=1440",
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=5",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL > SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
}
}
func TestValidateSchedulerEntryFrequencyMinLessThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=5",
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=1440",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
+43
View File
@@ -50,6 +50,49 @@ func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
return cp.options, nil
}
// Validate checks for invalid or incomplete option combinations.
func (c *configOptions) Validate() error {
if c.OAuth2Provider() == "oidc" && c.OAuth2OIDCDiscoveryEndpoint() == "" {
return errors.New("OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider")
}
if c.DisableLocalAuth() {
if c.OAuth2Provider() == "" && c.AuthProxyHeader() == "" {
return errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is set. Please enable at least one authentication source")
}
}
if c.AuthProxyHeader() != "" && len(c.TrustedReverseProxyNetworks()) == 0 {
return errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used")
}
if (c.CertFile() != "") != (c.CertKeyFile() != "") {
return errors.New("CERT_FILE and KEY_FILE must both be provided")
}
if c.CertDomain() != "" && c.CertFile() != "" {
return errors.New("CERT_DOMAIN and CERT_FILE/KEY_FILE are mutually exclusive")
}
if (c.MetricsUsername() != "") != (c.MetricsPassword() != "") {
return errors.New("METRICS_USERNAME and METRICS_PASSWORD must both be provided")
}
if c.DatabaseMinConns() > c.DatabaseMaxConns() {
return errors.New("DATABASE_MIN_CONNS must be less than or equal to DATABASE_MAX_CONNS")
}
if c.SchedulerRoundRobinMinInterval() > c.SchedulerRoundRobinMaxInterval() {
return errors.New("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL must be less than or equal to SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
}
if c.SchedulerEntryFrequencyMinInterval() > c.SchedulerEntryFrequencyMaxInterval() {
return errors.New("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL must be less than or equal to SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
}
return nil
}
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].parsedStringValue
+122 -4
View File
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"errors"
"miniflux.app/v2/internal/crypto"
)
@@ -352,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
},
@@ -483,7 +488,7 @@ var migrations = [...]func(tx *sql.Tx) error{
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -723,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
}
@@ -1081,7 +1091,7 @@ var migrations = [...]func(tx *sql.Tx) error{
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -1427,4 +1437,112 @@ var migrations = [...]func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS user_sessions;
CREATE TABLE web_sessions (
id text not null,
secret_hash bytea not null,
user_id int references users(id) on delete cascade,
created_at timestamp with time zone not null default now(),
user_agent text not null default '',
ip inet,
state jsonb not null default '{}'::jsonb,
primary key (id),
check (jsonb_typeof(state) = 'object')
);
CREATE INDEX web_sessions_user_id_idx
ON web_sessions (user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX web_sessions_created_at_idx
ON web_sessions (created_at);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE entry_tombstones (
feed_id bigint not null references feeds(id) on delete cascade,
hash text not null check (hash <> ''),
deleted_at timestamp with time zone not null default now(),
primary key (feed_id, hash)
);
CREATE INDEX entry_tombstones_deleted_at_idx
ON entry_tombstones (deleted_at);
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
SELECT feed_id, hash, changed_at
FROM entries
WHERE status = 'removed' AND hash <> ''
ON CONFLICT (feed_id, hash) DO NOTHING;
DELETE FROM entries WHERE status = 'removed';
-- The "removed" status is no longer used, so drop the partial
-- predicate so the planner can use the index for every search.
DROP INDEX document_vectors_idx;
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DELETE FROM integrations WHERE user_id NOT IN (SELECT id FROM users);
ALTER TABLE integrations
ADD CONSTRAINT integrations_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
`)
return err
},
func(tx *sql.Tx) (err error) {
// backup_eligible is nullable: NULL marks pre-migration rows so the login path can backfill it from the assertion on first use.
_, err = tx.Exec(`
UPDATE webauthn_credentials SET name = '' WHERE name IS NULL;
ALTER TABLE webauthn_credentials
ALTER COLUMN name SET DEFAULT '',
ALTER COLUMN name SET NOT NULL,
ADD COLUMN backup_eligible boolean,
ADD COLUMN backup_state boolean NOT NULL DEFAULT false;
`)
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
},
}
+387
View File
@@ -0,0 +1,387 @@
# Miniflux Fever API
This document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.
## Endpoint
- Path: `BASE_URL/fever/`
- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`
- Response format: JSON only
- Reported API version: `3`
## Authentication
Fever authentication is enabled per user from the Miniflux integrations page.
- `Fever Username` and `Fever Password` are configured in Miniflux
- Miniflux stores the Fever token as the MD5 hash of `username:password`
- Clients authenticate by sending that token as the `api_key` parameter
- Token lookup is case-insensitive
Example:
```text
api_key = md5("fever_username:fever_password")
```
Example shell command:
```bash
printf '%s' 'fever_username:fever_password' | md5sum
```
Authentication failure does not return HTTP 401. The middleware returns HTTP 200 with:
```json
{
"api_version": 3,
"auth": 0
}
```
On successful authentication, every response includes:
- `api_version`: always `3`
- `auth`: always `1`
- `last_refreshed_on_time`: current server Unix timestamp at response time
## Dispatch Rules
The handler selects the first matching operation in this order:
1. `groups`
2. `feeds`
3. `favicons`
4. `unread_item_ids`
5. `saved_item_ids`
6. `items`
7. `mark=item`
8. `mark=feed`
9. `mark=group`
If no selector is provided, the server returns the base authenticated response only.
For read operations, the selector must be present in the query string. For write operations, `mark`, `as`, `id`, and `before` are read from request form values, so they may come from the query string or a form body.
## Read Operations
### `?groups`
Returns:
- `groups`: list of categories
- `feeds_groups`: mapping of category IDs to feed IDs
Response shape:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"groups": [
{
"id": 1,
"title": "All"
}
],
"feeds_groups": [
{
"group_id": 1,
"feed_ids": "10,11"
}
]
}
```
Notes:
- `groups` are Miniflux categories
- `feeds_groups.feed_ids` is a comma-separated string
- categories with no feeds are returned in `groups` but have no `feeds_groups` entry
### `?feeds`
Returns:
- `feeds`: list of feeds
- `feeds_groups`: mapping of category IDs to feed IDs
Feed fields:
- `id`
- `favicon_id`
- `title`
- `url`
- `site_url`
- `is_spark`
- `last_updated_on_time`
Notes:
- `favicon_id` is `0` when the feed has no icon
- `is_spark` is always `0` in this implementation
- `last_updated_on_time` is the feed check time as a Unix timestamp
### `?favicons`
Returns:
- `favicons`: list of favicon objects
Favicon fields:
- `id`
- `data`
Notes:
- `data` is a data URL such as `image/png;base64,...`
### `?unread_item_ids`
Returns:
- `unread_item_ids`: comma-separated list of unread entry IDs
Response shape:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"unread_item_ids": "100,101,102"
}
```
### `?saved_item_ids`
Returns:
- `saved_item_ids`: comma-separated list of starred entry IDs
### `?items`
Returns:
- `items`: list of entries
- `total_items`: total number of non-removed entries for the user
Item fields:
- `id`
- `feed_id`
- `title`
- `author`
- `html`
- `url`
- `is_saved`
- `is_read`
- `created_on_time`
The implementation always excludes entries whose status is `removed`.
#### Pagination and filtering
The handler applies a fixed limit of 50 items.
Supported parameters:
- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`
- `max_id`: when equal to `0`, returns the most recent entries ordered by `id DESC`; when greater than `0`, returns entries with `id < max_id`, ordered by `id DESC`
- `with_ids`: comma-separated list of entry IDs to fetch
Selector precedence inside `?items` is:
1. `since_id`
2. `max_id`
3. `with_ids`
4. no item filter
Notes:
- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation
- invalid `with_ids` members are parsed as `0` and do not match normal entries
- when `items` is requested without `since_id`, `max_id`, or `with_ids`, the code applies no explicit `ORDER BY`, so result ordering is not guaranteed by SQL
- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs
Example:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"total_items": 245,
"items": [
{
"id": 100,
"feed_id": 10,
"title": "Example entry",
"author": "Author",
"html": "<p>Content</p>",
"url": "https://example.org/post",
"is_saved": 0,
"is_read": 1,
"created_on_time": 1709990000
}
]
}
```
## Write Operations
Normal successful write operations return the base authenticated response:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000
}
```
### `mark=item`
Parameters:
- `mark=item`
- `id=<entry_id>`
- `as=read|unread|saved|unsaved`
Behavior:
- `as=read`: marks the entry as read
- `as=unread`: marks the entry as unread
- `as=saved`: toggles the starred flag
- `as=unsaved`: toggles the starred flag
Important:
- `saved` and `unsaved` both call the same toggle operation
- sending `as=saved` twice will save, then unsave
- sending `as=unsaved` twice will unsave, then save
- if `id <= 0`, the handler returns without writing a response body
- if the entry does not exist or is already removed, the server returns the base response without an error
### `mark=feed`
Parameters:
- `mark=feed`
- `as=read`
- `id=<feed_id>`
- `before=<unix_timestamp>`
Behavior:
- marks unread entries in the feed as read when `published_at < before`
- the update runs asynchronously in a goroutine after the response is returned
Notes:
- if `id <= 0`, the handler returns without writing a response body
- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read
### `mark=group`
Parameters:
- `mark=group`
- `as=read`
- `id=<group_id>`
- `before=<unix_timestamp>`
Behavior:
- `id=0`: marks all unread entries as read, ignoring `before`
- `id>0`: marks unread entries in the matching category as read when `published_at < before`
- the update runs asynchronously in a goroutine after the response is returned
Notes:
- group IDs map to Miniflux category IDs
- if `id < 0`, the handler returns without writing a response body
- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read
## Error Handling
Authentication failures:
- HTTP status: `200`
- body: `{"api_version":3,"auth":0}`
Internal errors:
- HTTP status: `500`
- body:
```json
{
"error_message": "..."
}
```
## Differences From Generic Fever Documentation
This implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.
- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented
- `api_version` is `3`
- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed
- the `Kindling` and `Sparks` super groups are not returned
- `feeds[].is_spark` is always `0`
- item ordering without explicit pagination parameters is unspecified
- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely
## Examples
Fetch groups:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'
```
Fetch most recent items:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'
```
Fetch items after a known ID:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'
```
Mark an item as read:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=item' \
-d 'as=read' \
-d 'id=123'
```
Mark a feed as read before a timestamp:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=feed' \
-d 'as=read' \
-d 'id=10' \
-d 'before=1710000000'
```
Mark all items as read through the group endpoint:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=group' \
-d 'as=read' \
-d 'id=0'
```
+86 -107
View File
@@ -11,30 +11,24 @@ import (
"time"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"github.com/gorilla/mux"
)
// Serve handles Fever API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
// NewHandler returns an http.Handler for Fever API calls.
func NewHandler(store *storage.Storage) http.Handler {
h := &feverHandler{store: store}
return http.HandlerFunc(h.serve)
}
type handler struct {
store *storage.Storage
router *mux.Router
type feverHandler struct {
store *storage.Storage
}
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {
switch {
case request.HasQueryParam(r, "groups"):
h.handleGroups(w, r)
@@ -55,7 +49,7 @@ func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
case r.FormValue("mark") == "group":
h.handleWriteGroups(w, r)
default:
json.OK(w, r, newBaseResponse())
response.JSON(w, r, newBaseResponse())
}
}
@@ -78,7 +72,7 @@ an is_spark equal to 0.
The Sparks super group is not included in this response and is composed of all feeds with an
is_spark equal to 1.
*/
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching groups",
slog.Int64("user_id", userID),
@@ -86,13 +80,13 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
categories, err := h.store.Categories(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -101,9 +95,9 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
}
result.FeedsGroups = h.buildFeedGroups(feeds)
result.FeedsGroups = buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -130,7 +124,7 @@ should be limited to feeds with an is_spark equal to 0.
For the Sparks super group the items should be limited to feeds with an is_spark equal to 1.
*/
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching feeds",
slog.Int64("user_id", userID),
@@ -138,7 +132,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -161,9 +155,9 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
result.Feeds = append(result.Feeds, subscription)
}
result.FeedsGroups = h.buildFeedGroups(feeds)
result.FeedsGroups = buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -185,7 +179,7 @@ A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">';
*/
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching favicons",
slog.Int64("user_id", userID),
@@ -193,7 +187,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
icons, err := h.store.Icons(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -206,7 +200,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
}
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -239,14 +233,13 @@ Three optional arguments control determine the items included in the response.
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
(added in API version 2)
*/
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50)
builder := h.store.NewEntryQueryBuilder(userID).
WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
@@ -256,8 +249,8 @@ func (h *handler) 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)
@@ -265,14 +258,14 @@ func (h *handler) 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", "")
@@ -285,7 +278,7 @@ func (h *handler) 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",
@@ -295,15 +288,14 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
result.Total, err = h.store.NewEntryQueryBuilder(userID).
CountEntries()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -324,7 +316,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content),
URL: entry.URL,
IsSaved: isSaved,
IsRead: isRead,
@@ -333,7 +325,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
}
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -344,17 +336,17 @@ A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching unread items",
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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -366,7 +358,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -377,18 +369,17 @@ with the remote Fever installation.
saved_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching saved items",
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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -399,7 +390,7 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -407,7 +398,7 @@ mark=item
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Receiving mark=item call",
slog.Int64("user_id", userID),
@@ -418,13 +409,11 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -433,7 +422,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
json.OK(w, r, newBaseResponse())
response.JSON(w, r, newBaseResponse())
return
}
@@ -456,13 +445,13 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
settings, err := h.store.Integration(userID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -475,12 +464,12 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
}
json.OK(w, r, newBaseResponse())
response.JSON(w, r, newBaseResponse())
}
/*
@@ -489,7 +478,7 @@ as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
@@ -504,18 +493,12 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
return
}
go func() {
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
slog.Error("[Fever] Unable to mark feed as read",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
}
}()
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, newBaseResponse())
response.JSON(w, r, newBaseResponse())
}
/*
@@ -524,41 +507,37 @@ as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
func (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
slog.Debug("[Fever] Mark group as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
)
if groupID < 0 {
return
}
go func() {
var err error
var err error
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
} else {
err = h.store.MarkCategoryAsRead(userID, groupID, before)
}
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
slog.Debug("[Fever] Mark all items as read",
slog.Int64("user_id", userID),
)
} else {
before := time.Unix(request.FormInt64Value(r, "before"), 0)
err = h.store.MarkCategoryAsRead(userID, groupID, before)
slog.Debug("[Fever] Mark group as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
)
}
if err != nil {
slog.Error("[Fever] Unable to mark group as read",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
slog.Any("error", err),
)
}
}()
if err != nil {
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, newBaseResponse())
response.JSON(w, r, newBaseResponse())
}
/*
@@ -567,7 +546,7 @@ A feeds_group object has the following members:
group_id (positive integer)
feed_ids (string/comma-separated list of positive integers)
*/
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
func buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
+50 -55
View File
@@ -9,70 +9,65 @@ import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
store *storage.Storage
}
// Middleware returns the Fever authentication middleware.
func Middleware(store *storage.Storage) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
apiKey := r.FormValue("api_key")
if apiKey == "" {
slog.Warn("[Fever] No API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
user, err := store.UserByFeverToken(apiKey)
if err != nil {
slog.Error("[Fever] Unable to fetch user by API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
func (m *middleware) serve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
apiKey := r.FormValue("api_key")
if apiKey == "" {
slog.Warn("[Fever] No API key provided",
slog.Bool("authentication_failed", true),
if user == nil {
slog.Warn("[Fever] No user found with the API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
slog.Info("[Fever] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
json.OK(w, r, newAuthFailureResponse())
return
}
user, err := m.store.UserByFeverToken(apiKey)
if err != nil {
slog.Error("[Fever] Unable to fetch user by API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
json.OK(w, r, newAuthFailureResponse())
return
}
store.SetLastLogin(user.ID)
if user == nil {
slog.Warn("[Fever] No user found with the API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
json.OK(w, r, newAuthFailureResponse())
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
slog.Info("[Fever] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
+591
View File
@@ -0,0 +1,591 @@
# Miniflux Google Reader API
This document describes the Google Reader compatible API implemented by the `internal/googlereader` package in this repository.
Miniflux implements a compatibility subset intended for existing Google Reader clients. It is not a full reimplementation of the historical Google Reader API, and several behaviors are intentionally narrower or implementation-specific.
## Endpoint
- Client login path: `BASE_URL/accounts/ClientLogin`
- API prefix: `BASE_URL/reader/api/0`
- `BASE_URL` includes the Miniflux root URL and any configured `BasePath`
- Response format:
- `ClientLogin`: plain text by default, JSON when `output=json`
- most API reads: JSON
- most API writes: plain text `OK`
## Enabling the API
Google Reader compatibility is configured per user from the Miniflux integrations page.
- `Google Reader API` must be enabled
- `Google Reader Username` must be unique across all Miniflux users
- `Google Reader Password` is stored as a bcrypt hash
The Google Reader username and password are separate integration credentials. They are not the Miniflux account password.
## Authentication
### `POST /accounts/ClientLogin`
This endpoint exchanges the configured Google Reader username and password for an auth token.
Form parameters:
- `Email`: Google Reader username
- `Passwd`: Google Reader password
- `output`: optional, set to `json` for a JSON response
Successful responses:
- default: plain text
- with `output=json`: JSON
Example plain-text response:
```text
SID=readeruser/0123456789abcdef...
LSID=readeruser/0123456789abcdef...
Auth=readeruser/0123456789abcdef...
```
Example JSON response:
```json
{
"SID": "readeruser/0123456789abcdef...",
"LSID": "readeruser/0123456789abcdef...",
"Auth": "readeruser/0123456789abcdef..."
}
```
On authentication failure, `ClientLogin` returns HTTP `401` with the normal JSON error body:
```json
{
"error_message": "access unauthorized"
}
```
### Auth token format
The token format is:
```text
<googlereader_username>/<hex_digest>
```
The digest is generated server-side from:
- the Google Reader username
- the stored bcrypt hash of the Google Reader password
Specifically, the code computes an HMAC-SHA256 digest of an empty message using the key:
```text
googlereader_username + bcrypt_hash
```
Because the bcrypt hash is only known to the server, clients should not try to precompute the token. Use `ClientLogin` or `GET /reader/api/0/token`.
### Authenticating API calls
Miniflux uses different auth mechanisms for `GET` and `POST` requests:
- `GET` requests must send the header `Authorization: GoogleLogin auth=<token>`
- `POST` requests are authenticated with `T=<token>` read from the parsed form values
Notes:
- the auth scheme must be exactly `GoogleLogin`
- the auth field name must be exactly lowercase `auth`
- for `POST`, `T` may come from the URL query or the form body because the server reads merged form values
- `POST` requests do not accept the token from the `Authorization` header
- `GET` requests do not accept the token from the query string
### `GET /reader/api/0/token`
This endpoint requires normal `GET` authentication and returns the same token as plain text.
Many Google Reader clients use this as the edit token for subsequent write requests. In Miniflux, the edit token and auth token are the same value.
### Authentication failure on `/reader/api/0/*`
When API authentication fails under `/reader/api/0`, Miniflux returns:
- HTTP `401`
- header `X-Reader-Google-Bad-Token: true`
- content type `text/plain; charset=utf-8`
- body `Unauthorized`
This is different from `ClientLogin`, which returns a JSON `401`.
## Identifier formats
### Stream IDs
The implementation recognizes these stream forms:
- built-in streams:
- `user/-/state/com.google/read`
- `user/-/state/com.google/starred`
- `user/-/state/com.google/reading-list`
- `user/-/state/com.google/kept-unread`
- `user/-/state/com.google/broadcast`
- `user/-/state/com.google/broadcast-friends`
- `user/-/state/com.google/like`
- user-specific equivalents:
- `user/<user_id>/state/com.google/...`
- label streams:
- `user/-/label/<name>`
- `user/<user_id>/label/<name>`
- feed streams:
- `feed/<value>`
Important feed stream difference:
- read APIs usually emit `feed/<numeric_feed_id>`
- `subscription/edit` with `ac=subscribe` expects `feed/<absolute_feed_url>`
- `subscription/edit` with `ac=edit` or `ac=unsubscribe` expects `feed/<numeric_feed_id>`
So `feed/<...>` is not a single stable identifier format across all endpoints.
### Item IDs
`edit-tag` and `stream/items/contents` accept repeated `i` parameters in all of these formats:
- long Google Reader form: `tag:google.com,2005:reader/item/00000000148b9369`
- short prefixed hexadecimal form: `tag:google.com,2005:reader/item/2f2`
- bare 16-character hexadecimal form: `000000000000048c`
- decimal entry ID: `12345`
Responses use different forms depending on endpoint:
- `stream/items/ids` returns decimal IDs as strings
- `stream/items/contents` returns long-form Google Reader item IDs
## Common response conventions
JSON errors use this shape:
```json
{
"error_message": "..."
}
```
Plain-text success responses from write endpoints are usually:
```text
OK
```
## POST parameter parsing
Most `POST` handlers call `ParseForm()` and read from `r.Form`, so parameters may be supplied either in the query string or in a standard form body.
Important exception:
- `POST /reader/api/0/edit-tag` reads `a` and `r` from `r.PostForm`, so those tag lists must come from the request body
Because `GET` auth comes only from the `Authorization` header, query parameters never authenticate `GET` requests even when other parameters are read from the query string.
## Endpoint reference
### `GET /reader/api/0/user-info`
Returns JSON only. No `output=json` parameter is required.
Response fields:
- `userId`: Miniflux user ID as a string
- `userName`: Miniflux username
- `userProfileId`: same value as `userId`
- `userEmail`: same value as `userName`
Example:
```json
{
"userId": "1",
"userName": "demo",
"userProfileId": "1",
"userEmail": "demo"
}
```
### `GET /reader/api/0/tag/list?output=json`
Returns the starred state and user labels.
Notes:
- `output=json` is required
- only labels and the starred state are returned
- built-in states such as `read` and `reading-list` are not listed here
Response shape:
```json
{
"tags": [
{
"id": "user/1/state/com.google/starred"
},
{
"id": "user/1/label/Tech",
"label": "Tech",
"type": "folder"
}
]
}
```
### `GET /reader/api/0/subscription/list?output=json`
Returns the user's feeds.
Notes:
- `output=json` is required
- each feed is reported with a numeric feed stream ID such as `feed/42`
- `categories` always contains the Miniflux category as a Google Reader folder
Response shape:
```json
{
"subscriptions": [
{
"id": "feed/42",
"title": "Example Feed",
"categories": [
{
"id": "user/1/label/Tech",
"label": "Tech",
"type": "folder"
}
],
"url": "https://example.org/feed.xml",
"htmlUrl": "https://example.org/",
"iconUrl": "https://miniflux.example.com/icon/..."
}
]
}
```
### `POST /reader/api/0/subscription/quickadd`
Subscribes to the first discovered feed for the given absolute URL.
Form parameters:
- `T`: auth token
- `quickadd`: absolute URL
Response shape when a feed is found:
```json
{
"numResults": 1,
"query": "https://example.org/feed.xml",
"streamId": "feed/42",
"streamName": "Example Feed"
}
```
Response shape when no feed is found:
```json
{
"numResults": 0
}
```
Notes:
- the request URL must be absolute
- the created subscription is assigned to the user's first category when no explicit category is provided
### `POST /reader/api/0/subscription/edit`
Edits subscriptions. Successful requests return plain text `OK`.
Form parameters:
- `T`: auth token
- `ac`: action
- `s`: repeated stream ID
- `a`: optional destination label stream
- `t`: optional title
Supported actions:
- `ac=subscribe`
- `ac=unsubscribe`
- `ac=edit`
Behavior by action:
- `subscribe`
- only the first `s` value is used
- `s` must be `feed/<absolute_feed_url>`
- `a`, when present, must be a label stream
- `t`, when present, becomes the feed title after creation
- `unsubscribe`
- every `s` must be `feed/<numeric_feed_id>`
- `edit`
- only the first `s` value is used
- `s` must be `feed/<numeric_feed_id>`
- `t` renames the feed
- `a` moves the feed to a label, and must be a label stream
Notable limitations:
- removing a label is not implemented here
- `subscribe`, `edit`, and `unsubscribe` do not share the same feed ID format
### `POST /reader/api/0/rename-tag`
Renames a label. Successful requests return plain text `OK`.
Form parameters:
- `T`: auth token
- `s`: source label stream
- `dest`: destination label stream
Rules:
- both `s` and `dest` must be label streams
- the destination label name must not be empty
- if the source label does not exist, the endpoint returns HTTP `404`
### `POST /reader/api/0/disable-tag`
Deletes one or more labels and reassigns affected feeds to the user's first remaining category.
Form parameters:
- `T`: auth token
- `s`: repeated label stream
Rules:
- only label streams are supported
- at least one category must remain after deletion, otherwise the operation fails
Successful requests return plain text `OK`.
### `POST /reader/api/0/edit-tag`
Marks entries read or unread and starred or unstarred.
Form parameters:
- `T`: auth token
- `i`: repeated item ID
- `a`: repeated tag stream to add
- `r`: repeated tag stream to remove
Supported tag semantics:
- add `user/.../state/com.google/read`: mark read
- remove `user/.../state/com.google/read`: mark unread
- add `user/.../state/com.google/kept-unread`: mark unread
- remove `user/.../state/com.google/kept-unread`: mark read
- add `user/.../state/com.google/starred`: star
- remove `user/.../state/com.google/starred`: unstar
Special cases:
- `read` and `kept-unread` cannot be combined in conflicting ways in the same request
- `starred` cannot be present in both add and remove
- `broadcast` and `like` are recognized but ignored
- unsupported tag types cause an error
Successful requests return plain text `OK`.
### `GET /reader/api/0/stream/items/ids?output=json`
Returns item IDs for one stream.
Required query parameters:
- `output=json`
- `s=<stream_id>`
Optional query parameters:
- `n`: maximum number of items to return
- `c`: numeric offset continuation token
- `r`: sort direction, `o` for ascending, anything else for descending
- `ot`: only items published after this Unix timestamp in seconds
- `nt`: only items published before this Unix timestamp in seconds
- `xt`: repeated exclude target stream
- `it`: repeated filter target stream, parsed but currently ignored
Supported `s` values:
- `user/.../state/com.google/reading-list`
- `user/.../state/com.google/starred`
- `user/.../state/com.google/read`
- `feed/<numeric_feed_id>`
Notes:
- exactly one `s` value is expected
- label streams are not supported here
- when `xt` contains the `read` stream, `reading-list` and `feed/<id>` behave as unread-only queries
- if `n` is omitted, the query is effectively unbounded
- `continuation` is a numeric offset encoded as a JSON string, not an opaque token
Response shape:
```json
{
"itemRefs": [
{
"id": "12345"
},
{
"id": "12344"
}
],
"continuation": "2"
}
```
### `POST /reader/api/0/stream/items/contents`
Returns content for specific items.
Required parameters:
- `T`: auth token
- `output=json`
- `i`: repeated item ID
Optional query parameters:
- `r`: sort direction, `o` for ascending, anything else for descending
Implementation notes:
- the route is `POST` only
- `T`, `output`, and `i` are read from merged form values, so they may be supplied in the query string or the form body
- the handler parses stream filter query parameters, but in practice only the sort direction affects the result
Response shape:
```json
{
"direction": "ltr",
"id": "user/-/state/com.google/reading-list",
"title": "Reading List",
"self": [
{
"href": "https://miniflux.example.com/reader/api/0/stream/items/contents"
}
],
"updated": 1710000000,
"author": "demo",
"items": [
{
"id": "tag:google.com,2005:reader/item/00000000148b9369",
"categories": [
"user/1/state/com.google/reading-list",
"user/1/label/Tech",
"user/1/state/com.google/starred"
],
"title": "Example entry",
"crawlTimeMsec": "1710000000123",
"timestampUsec": "1710000000123456",
"published": 1710000000,
"updated": 1710000300,
"author": "Author",
"alternate": [
{
"href": "https://example.org/post",
"type": "text/html"
}
],
"summary": {
"direction": "ltr",
"content": "<p>Content</p>"
},
"content": {
"direction": "ltr",
"content": "<p>Content</p>"
},
"origin": {
"streamId": "feed/42",
"title": "Example Feed",
"htmlUrl": "https://example.org/"
},
"enclosure": [],
"canonical": [
{
"href": "https://example.org/post"
}
]
}
]
}
```
Notes:
- top-level `id` and `title` are hard-coded as the reading list
- `summary.content` and `content.content` both contain the rewritten entry content
- enclosure URLs and embedded media may be rewritten through the Miniflux media proxy
### `POST /reader/api/0/mark-all-as-read`
Marks items as read before a timestamp. Successful requests return plain text `OK`.
Form parameters:
- `T`: auth token
- `s`: stream ID
- `ts`: optional timestamp
Supported `s` values:
- `feed/<numeric_feed_id>`
- `user/.../label/<name>`
- `user/.../state/com.google/reading-list`
Timestamp handling:
- if `ts` has at least 16 digits, it is interpreted as microseconds since the Unix epoch
- otherwise it is interpreted as seconds since the Unix epoch
- if `ts` is omitted, Miniflux uses the current server time
Notes:
- only unread entries published before `ts` are marked as read
- unsupported stream types are effectively a no-op and still return `OK`
### Catch-all unimplemented endpoints
Any other `GET` or `POST` path under `/reader/api/0/` is caught by the fallback handler and returns:
```json
[]
```
with HTTP `200`.
## Compatibility notes and deviations
These differences are important for client authors:
- only a subset of Google Reader endpoints is implemented
- feed stream IDs are numeric in read responses, but `ac=subscribe` expects `feed/<absolute_feed_url>`
- `stream/items/ids` returns decimal entry IDs, while `stream/items/contents` returns long-form Google Reader item IDs
- pagination uses `c` as a numeric SQL offset, not an opaque continuation token
- `it` filter targets are parsed but currently ignored
- `tag/list` returns only `starred` and user labels
- API auth failures under `/reader/api/0/*` return plain text `401 Unauthorized`, not JSON
- unknown `/reader/api/0/*` endpoints return `[]` with `200`, not `404`
File diff suppressed because it is too large Load Diff
+155 -150
View File
@@ -6,176 +6,181 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"strings"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
type authMiddleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
func newAuthMiddleware(s *storage.Storage) *authMiddleware {
return &authMiddleware{s}
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
var token string
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
token = r.Form.Get("T")
if token == "" {
slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
} else {
authorization := r.Header.Get("Authorization")
if authorization == "" {
slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
fields := strings.Fields(authorization)
if len(fields) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if fields[0] != "GoogleLogin" {
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
auths := strings.Split(fields[1], "=")
if len(auths) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if auths[0] != "auth" {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
token = auths[1]
}
parts := strings.Split(token, "/")
if len(parts) != 2 {
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w)
return
}
var integration *model.Integration
var user *model.User
var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if expectedToken != token {
slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
return
}
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
return
}
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
m.serveValidated(w, r, next)
})
}
func (m *authMiddleware) serveValidated(w http.ResponseWriter, r *http.Request, next http.Handler) {
clientIP := request.ClientIP(r)
var token string
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
token = r.Form.Get("T")
if token == "" {
slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
} else {
authorization := r.Header.Get("Authorization")
if authorization == "" {
slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
fields := strings.Fields(authorization)
if len(fields) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if fields[0] != "GoogleLogin" {
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
auths := strings.Split(fields[1], "=")
if len(auths) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if auths[0] != "auth" {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
token = auths[1]
}
parts := strings.Split(token, "/")
if len(parts) != 2 {
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w, r)
return
}
var integration *model.Integration
var user *model.User
var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if !crypto.ConstantTimeCmp(expectedToken, token) {
slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
}
func getAuthToken(username, password string) string {
token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))
token := hex.EncodeToString(hmac.New(sha256.New, []byte(username+password)).Sum(nil))
token = username + "/" + token
return token
}
+9 -11
View File
@@ -6,6 +6,8 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"miniflux.app/v2/internal/http/response"
)
type loginResponse struct {
@@ -117,15 +119,11 @@ type contentItemOrigin struct {
HTMLUrl string `json:"htmlUrl"`
}
func sendUnauthorizedResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Reader-Google-Bad-Token", "true")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
}
func sendOkayResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
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()
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/internal/http/client"
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
)
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
// Options holds configuration for creating an HTTP client.
type Options struct {
Timeout time.Duration
BlockPrivateNetworks bool
}
// NewClientWithOptions creates a new HTTP client with the specified options.
func NewClientWithOptions(opts Options) *http.Client {
if !opts.BlockPrivateNetworks {
return &http.Client{Timeout: opts.Timeout}
}
dialer := &net.Dialer{
Timeout: opts.Timeout,
}
transport := &http.Transport{
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
}
var safeIP net.IP
for _, ip := range ips {
if !urllib.IsNonPublicIP(ip) {
safeIP = ip
break
}
}
if safeIP == nil {
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
}
safeAddr := net.JoinHostPort(safeIP.String(), port)
return dialer.DialContext(ctx, network, safeAddr)
},
}
return &http.Client{
Timeout: opts.Timeout,
Transport: transport,
}
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err := client.Get(server.URL)
if err == nil {
t.Fatal("Expected an error when connecting to loopback address, got nil")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
}
}
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
if client == nil {
t.Fatal("Expected non-nil client")
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
}
if transport.DialContext == nil {
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
}
}
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
if client.Transport != nil {
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
}
}
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.Listener = listener
server.Start()
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err = client.Get(server.URL)
if err == nil {
t.Fatal("Expected error when connecting to private IP")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
}
}
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
-51
View File
@@ -1,51 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
)
// New creates a new cookie.
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
SameSite: http.SameSiteLaxMode,
}
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
SameSite: http.SameSiteLaxMode,
}
}
func basePath(path string) string {
if path == "" {
return "/"
}
return path
}
+26 -83
View File
@@ -5,8 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/model"
)
@@ -21,26 +19,16 @@ const (
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
UserSessionTokenContextKey
UserLanguageContextKey
UserThemeContextKey
SessionIDContextKey
CSRFContextKey
OAuth2StateContextKey
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
LastForceRefreshContextKey
WebSessionContextKey
ClientIPContextKey
GoogleReaderTokenKey
WebAuthnDataContextKey
)
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid {
return &value
// WebSession returns the current web session from the request context, if present.
func WebSession(r *http.Request) *model.WebSession {
if v := r.Context().Value(WebSessionContextKey); v != nil {
if value, valid := v.(*model.WebSession); valid {
return value
}
}
return nil
@@ -58,12 +46,30 @@ func IsAdminUser(r *http.Request) bool {
// IsAuthenticated reports whether the user is authenticated.
func IsAuthenticated(r *http.Request) bool {
return getContextBoolValue(r, IsAuthenticatedContextKey)
if getContextBoolValue(r, IsAuthenticatedContextKey) {
return true
}
if session := WebSession(r); session != nil {
return session.IsAuthenticated()
}
return false
}
// UserID returns the logged-in user's ID from the request context.
func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
return userID
}
if session := WebSession(r); session != nil {
if id, ok := session.UserID(); ok {
return id
}
}
return 0
}
// UserName returns the logged-in user's username, or "unknown" when unset.
@@ -84,69 +90,6 @@ func UserTimezone(r *http.Request) string {
return value
}
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
func UserLanguage(r *http.Request) string {
language := getContextStringValue(r, UserLanguageContextKey)
if language == "" {
language = "en_US"
}
return language
}
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
func UserTheme(r *http.Request) string {
theme := getContextStringValue(r, UserThemeContextKey)
if theme == "" {
theme = "system_serif"
}
return theme
}
// CSRF returns the CSRF token from the request context.
func CSRF(r *http.Request) string {
return getContextStringValue(r, CSRFContextKey)
}
// SessionID returns the current session ID from the request context.
func SessionID(r *http.Request) string {
return getContextStringValue(r, SessionIDContextKey)
}
// UserSessionToken returns the current user session token from the request context.
func UserSessionToken(r *http.Request) string {
return getContextStringValue(r, UserSessionTokenContextKey)
}
// OAuth2State returns the OAuth2 state value from the request context.
func OAuth2State(r *http.Request) string {
return getContextStringValue(r, OAuth2StateContextKey)
}
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
func OAuth2CodeVerifier(r *http.Request) string {
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
}
// FlashMessage returns the flash message from the request context, if any.
func FlashMessage(r *http.Request) string {
return getContextStringValue(r, FlashMessageContextKey)
}
// FlashErrorMessage returns the flash error message from the request context, if any.
func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// LastForceRefresh returns the last force refresh timestamp from the request context.
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return time.Time{}
}
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the request context.
func ClientIP(r *http.Request) string {
return getContextStringValue(r, ClientIPContextKey)
+34 -250
View File
@@ -7,11 +7,16 @@ import (
"context"
"net/http"
"testing"
"time"
"miniflux.app/v2/internal/model"
)
func newRequestWithWebSession(session *model.WebSession) *http.Request {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
return r.WithContext(ctx)
}
func TestContextStringValue(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
@@ -171,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
session := &model.WebSession{}
session.SetUser(&model.User{ID: 42})
r = newRequestWithWebSession(session)
result = IsAuthenticated(r)
if !result {
t.Errorf("Unexpected context value, got %v instead of true", result)
}
}
func TestUserID(t *testing.T) {
@@ -193,6 +207,17 @@ func TestUserID(t *testing.T) {
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
session := &model.WebSession{}
session.SetUser(&model.User{ID: 456})
r = newRequestWithWebSession(session)
result = UserID(r)
expected = int64(456)
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestUserName(t *testing.T) {
@@ -239,262 +264,21 @@ func TestUserTimezone(t *testing.T) {
}
}
func TestUserLanguage(t *testing.T) {
func TestWebSession(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserLanguage(r)
expected := "en_US"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
if result := WebSession(r); result != nil {
t.Fatalf("Unexpected context value, got %v instead of nil", result)
}
session := &model.WebSession{ID: "session-id"}
ctx := r.Context()
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
ctx = context.WithValue(ctx, WebSessionContextKey, session)
r = r.WithContext(ctx)
result = UserLanguage(r)
expected = "fr_FR"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserTheme(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserTheme(r)
expected := "system_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
r = r.WithContext(ctx)
result = UserTheme(r)
expected = "dark_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestCSRF(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := CSRF(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
r = r.WithContext(ctx)
result = CSRF(r)
expected = "secret"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestSessionID(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := SessionID(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
r = r.WithContext(ctx)
result = SessionID(r)
expected = "id"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserSessionToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserSessionToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
r = r.WithContext(ctx)
result = UserSessionToken(r)
expected = "token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2State(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2State(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
r = r.WithContext(ctx)
result = OAuth2State(r)
expected = "state"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2CodeVerifier(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2CodeVerifier(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
r = r.WithContext(ctx)
result = OAuth2CodeVerifier(r)
expected = "verifier"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
r = r.WithContext(ctx)
result = FlashMessage(r)
expected = "message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashErrorMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashErrorMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
r = r.WithContext(ctx)
result = FlashErrorMessage(r)
expected = "error message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestLastForceRefresh(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := LastForceRefresh(r)
expected := time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx = r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Unix(1700000000, 0)
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestWebAuthnSessionData(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
ctx := r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
session := model.WebAuthnSession{}
ctx = r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result == nil {
t.Errorf("Unexpected context value, got nil instead of session")
result := WebSession(r)
if result == nil || result.ID != "session-id" {
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
}
}
+6 -6
View File
@@ -7,8 +7,6 @@ import (
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
@@ -24,8 +22,7 @@ func FormInt64Value(r *http.Request, param string) int64 {
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
func RouteInt64Param(r *http.Request, param string) int64 {
vars := mux.Vars(r)
value, err := strconv.ParseInt(vars[param], 10, 64)
value, err := strconv.ParseInt(routeParam(r, param), 10, 64)
if err != nil {
return 0
}
@@ -39,8 +36,7 @@ func RouteInt64Param(r *http.Request, param string) int64 {
// RouteStringParam returns the named route parameter as a string.
func RouteStringParam(r *http.Request, param string) string {
vars := mux.Vars(r)
return vars[param]
return routeParam(r, param)
}
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
@@ -129,3 +125,7 @@ func HasQueryParam(r *http.Request, param string) bool {
_, ok := values[param]
return ok
}
func routeParam(r *http.Request, param string) string {
return r.PathValue(param)
}
+8 -10
View File
@@ -9,8 +9,6 @@ import (
"net/url"
"reflect"
"testing"
"github.com/gorilla/mux"
)
func TestFormInt64Value(t *testing.T) {
@@ -42,9 +40,9 @@ func TestFormInt64Value(t *testing.T) {
}
}
func TestRouteStringParam(t *testing.T) {
router := mux.NewRouter()
router.HandleFunc("/route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
func TestRouteStringParamWithServerMux(t *testing.T) {
router := http.NewServeMux()
router.HandleFunc("GET /route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
result := RouteStringParam(r, "variable")
expected := "value"
@@ -60,7 +58,7 @@ func TestRouteStringParam(t *testing.T) {
}
})
r, err := http.NewRequest("GET", "/route/value/index", nil)
r, err := http.NewRequest(http.MethodGet, "/route/value/index", nil)
if err != nil {
t.Fatal(err)
}
@@ -69,9 +67,9 @@ func TestRouteStringParam(t *testing.T) {
router.ServeHTTP(w, r)
}
func TestRouteInt64Param(t *testing.T) {
router := mux.NewRouter()
router.HandleFunc("/a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
func TestRouteInt64ParamWithServerMux(t *testing.T) {
router := http.NewServeMux()
router.HandleFunc("GET /a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
result := RouteInt64Param(r, "variable1")
expected := int64(42)
@@ -101,7 +99,7 @@ func TestRouteInt64Param(t *testing.T) {
}
})
r, err := http.NewRequest("GET", "/a/42/b/not-int/c/-10", nil)
r, err := http.NewRequest(http.MethodGet, "/a/42/b/not-int/c/-10", nil)
if err != nil {
t.Fatal(err)
}
+78 -21
View File
@@ -6,8 +6,11 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"net/http"
"strings"
"time"
@@ -22,11 +25,16 @@ 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(http.Header), enableCompression: true}
}
// WithStatus uses the given status code to build the response.
func (b *Builder) WithStatus(statusCode int) *Builder {
b.statusCode = statusCode
@@ -35,19 +43,37 @@ 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
}
// WithBody uses the given body to build the response.
func (b *Builder) WithBody(body any) *Builder {
// WithBodyAsBytes uses the given bytes to build the response.
func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
b.body = body
return b
}
// WithBodyAsString uses the given string to build the response.
func (b *Builder) WithBodyAsString(body string) *Builder {
b.body = body
return b
}
// WithBodyAsReader uses the given reader to build the response.
func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
b.body = body
return b
}
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = "attachment; filename=" + 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.Set("Content-Disposition", formatContentDisposition("inline", filename))
return b
}
@@ -59,11 +85,14 @@ func (b *Builder) WithoutCompression() *Builder {
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public"
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
etag = normalizeETag(etag)
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 etag == b.r.Header.Get("If-None-Match") {
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
b.statusCode = http.StatusNotModified
b.body = nil
b.Write()
@@ -95,23 +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.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)
@@ -119,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)
@@ -127,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)
@@ -141,7 +169,36 @@ func (b *Builder) compress(data []byte) {
b.w.Write(data)
}
// New creates a new response builder.
func New(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
func normalizeETag(etag string) string {
etag = strings.TrimSpace(etag)
if etag == "" {
return ""
}
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
return etag
}
return `"` + etag + `"`
}
func ifNoneMatch(headerValue, etag string) bool {
if headerValue == "" || etag == "" {
return false
}
if strings.TrimSpace(headerValue) == "*" {
return true
}
// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
}
func formatContentDisposition(dispositionType, filename string) string {
if filename == "" {
return dispositionType
}
if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
return value
}
return dispositionType
}
+235 -43
View File
@@ -4,6 +4,8 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"bytes"
"mime"
"net/http"
"net/http/httptest"
"strings"
@@ -20,7 +22,7 @@ func TestResponseHasCommonHeaders(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).Write()
NewBuilder(w, r).Write()
})
handler.ServeHTTP(w, r)
@@ -48,7 +50,7 @@ func TestBuildResponseWithCustomStatusCode(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithStatus(http.StatusNotAcceptable).Write()
NewBuilder(w, r).WithStatus(http.StatusNotAcceptable).Write()
})
handler.ServeHTTP(w, r)
@@ -69,7 +71,7 @@ func TestBuildResponseWithCustomHeader(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithHeader("X-My-Header", "Value").Write()
NewBuilder(w, r).WithHeader("X-My-Header", "Value").Write()
})
handler.ServeHTTP(w, r)
@@ -91,7 +93,7 @@ func TestBuildResponseWithAttachment(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithAttachment("my_file.pdf").Write()
NewBuilder(w, r).WithAttachment("my_file.pdf").Write()
})
handler.ServeHTTP(w, r)
@@ -104,6 +106,90 @@ func TestBuildResponseWithAttachment(t *testing.T) {
}
}
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
actual := resp.Header.Get("Content-Disposition")
mediaType, params, err := mime.ParseMediaType(actual)
if err != nil {
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
}
if mediaType != "attachment" {
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
}
if params["filename"] != `a";filename="malware.exe` {
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
}
}
func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
actual := resp.Header.Get("Content-Disposition")
mediaType, params, err := mime.ParseMediaType(actual)
if err != nil {
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
}
if mediaType != "inline" {
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
}
if params["filename"] != `a";filename="malware.exe` {
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
}
}
func TestFormatContentDisposition(t *testing.T) {
tests := []struct {
name string
dispositionType string
filename string
expected string
}{
{"empty filename returns bare type", "inline", "", "inline"},
{"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
{"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
{"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
{"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := formatContentDisposition(tt.dispositionType, tt.filename)
if actual != tt.expected {
t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
}
})
}
}
func TestBuildResponseWithByteBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
@@ -113,7 +199,7 @@ func TestBuildResponseWithByteBody(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody([]byte("body")).Write()
NewBuilder(w, r).WithBodyAsBytes([]byte("body")).Write()
})
handler.ServeHTTP(w, r)
@@ -134,8 +220,8 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBody("cached body")
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBodyAsString("cached body")
b.Write()
})
})
@@ -154,55 +240,118 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public"
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)
}
if actualETag := resp.Header.Get("ETag"); actualETag != `"etag"` {
t.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `"etag"`)
}
if resp.Header.Get("Expires") == "" {
t.Fatalf(`Expires header should not be empty`)
}
}
func TestBuildResponseWithCachingAndEtag(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("If-None-Match", "etag")
if err != nil {
t.Fatal(err)
func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
tests := []struct {
name string
ifNoneMatch string
expectedStatus int
expectedBody string
}{
{"matching strong etag", `"etag"`, http.StatusNotModified, ""},
{"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
{"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
{"wildcard", `*`, http.StatusNotModified, ""},
{"non-matching etag", `"different"`, http.StatusOK, "cached body"},
}
w := httptest.NewRecorder()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
r.Header.Set("If-None-Match", tt.ifNoneMatch)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBody("cached body")
b.Write()
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
b.WithBodyAsString("cached body")
b.Write()
})
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != tt.expectedStatus {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
}
if actual := w.Body.String(); actual != tt.expectedBody {
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
}
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
}
if resp.Header.Get("Expires") == "" {
t.Fatalf(`Expires header should not be empty`)
}
})
})
}
}
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotModified
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
func TestNormalizeETag(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"abc", `"abc"`},
{`"already-quoted"`, `"already-quoted"`},
{`W/"weak"`, `W/"weak"`},
{"", ""},
{" spaced ", `"spaced"`},
}
expectedBody := ``
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if actual := normalizeETag(tt.input); actual != tt.expected {
t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
}
})
}
}
func TestIfNoneMatch(t *testing.T) {
tests := []struct {
name string
headerValue string
etag string
expected bool
}{
{"empty header", "", `"etag"`, false},
{"empty etag", `"etag"`, "", false},
{"exact match", `"etag"`, `"etag"`, true},
{"weak vs strong match", `W/"etag"`, `"etag"`, true},
{"wildcard", `*`, `"etag"`, true},
{"no match", `"other"`, `"etag"`, false},
{"match in list", `"a", "etag", "b"`, `"etag"`, true},
{"no match in list", `"a", "b", "c"`, `"etag"`, false},
}
expectedHeader := "public"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
}
if resp.Header.Get("Expires") == "" {
t.Fatalf(`Expires header should not be empty`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
}
})
}
}
@@ -217,7 +366,7 @@ func TestBuildResponseWithBrotliCompression(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
NewBuilder(w, r).WithBodyAsString(body).Write()
})
handler.ServeHTTP(w, r)
@@ -241,7 +390,7 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
NewBuilder(w, r).WithBodyAsString(body).Write()
})
handler.ServeHTTP(w, r)
@@ -265,7 +414,7 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
NewBuilder(w, r).WithBodyAsString(body).Write()
})
handler.ServeHTTP(w, r)
@@ -276,6 +425,12 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
expectedVary := "Accept-Encoding"
actualVary := resp.Header.Get("Vary")
if actualVary != expectedVary {
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
}
}
func TestBuildResponseWithCompressionDisabled(t *testing.T) {
@@ -289,7 +444,7 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).WithoutCompression().Write()
NewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()
})
handler.ServeHTTP(w, r)
@@ -300,6 +455,12 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
expectedVary := ""
actualVary := resp.Header.Get("Vary")
if actualVary != expectedVary {
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
}
}
func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
@@ -313,7 +474,7 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
NewBuilder(w, r).WithBodyAsString(body).Write()
})
handler.ServeHTTP(w, r)
@@ -324,6 +485,12 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
expectedVary := ""
actualVary := resp.Header.Get("Vary")
if actualVary != expectedVary {
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
}
}
func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
@@ -336,7 +503,7 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
NewBuilder(w, r).WithBodyAsString(body).Write()
})
handler.ServeHTTP(w, r)
@@ -347,4 +514,29 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
expectedVary := "Accept-Encoding"
actualVary := resp.Header.Get("Vary")
if actualVary != expectedVary {
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
}
}
func TestBuildResponseWithReaderBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString("body")).Write()
})
handler.ServeHTTP(w, r)
if actualBody := w.Body.String(); actualBody != "body" {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "body")
}
}
+154
View File
@@ -0,0 +1,154 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"fmt"
"html"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/urllib"
)
// 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).
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 = builder.WithBodyAsBytes(v)
case string:
builder = builder.WithBodyAsString(v)
}
builder.Write()
}
// HTMLServerError sends an internal error to the client.
func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
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.
func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
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.
func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
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.
func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
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.
func HTMLRedirect(w http.ResponseWriter, r *http.Request, uri string) {
if !urllib.IsRelativePath(uri) && !urllib.IsAbsoluteURL(uri) {
HTMLBadRequest(w, r, fmt.Errorf("invalid redirect URL: %q", uri))
return
}
http.Redirect(w, r, uri, http.StatusFound)
}
// HTMLRequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
),
)
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()
}
-142
View File
@@ -1,142 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"html"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
)
// OK creates a new HTML response with a 200 status code.
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(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.WithBody(body)
builder.Write()
}
// ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
// BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
// Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
builder := response.New(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.WithBody("Access Forbidden")
builder.Write()
}
// NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
builder := response.New(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.WithBody("Page Not Found")
builder.Write()
}
// Redirect redirects the user to another location.
func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
http.Redirect(w, r, uri, http.StatusFound)
}
// RequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
),
)
builder := response.New(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.WithBody("Range Not Satisfiable")
builder.Write()
}
-240
View File
@@ -1,240 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestOKResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, "Some HTML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some HTML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
headers := map[string]string{
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
}
for header, expected := range headers {
actual := resp.Header.Get(header)
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
}
func TestServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusBadRequest
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Forbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusForbidden
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Access Forbidden`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Page Not Found`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestRedirectResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Redirect(w, r, "/path")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedResult := "/path"
actualResult := resp.Header.Get("Location")
if actualResult != expectedResult {
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, expectedResult)
}
}
func TestRequestedRangeNotSatisfiable(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
RequestedRangeNotSatisfiable(w, r, "bytes */12777")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusRequestedRangeNotSatisfiable
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedContentRangeHeader := "bytes */12777"
actualContentRangeHeader := resp.Header.Get("Content-Range")
if actualContentRangeHeader != expectedContentRangeHeader {
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, expectedContentRangeHeader)
}
}
+282
View File
@@ -0,0 +1,282 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTMLResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTML(w, r, "Some HTML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
}
if actualBody := w.Body.String(); actualBody != `Some HTML` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some HTML`)
}
headers := map[string]string{
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
}
for header, expected := range headers {
if actual := resp.Header.Get(header); actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
}
func TestHTMLServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
}
if actualBody := w.Body.String(); actualBody != `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
}
}
func TestHTMLBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLBadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)
}
if actualBody := w.Body.String(); actualBody != `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
}
}
func TestHTMLForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLForbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)
}
if actualBody := w.Body.String(); actualBody != `Access Forbidden` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Access Forbidden`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/html; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/html; charset=utf-8")
}
}
func TestHTMLNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLNotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)
}
if actualBody := w.Body.String(); actualBody != `Page Not Found` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Page Not Found`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/html; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/html; charset=utf-8")
}
}
func TestHTMLRedirectResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLRedirect(w, r, "/path")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusFound)
}
if actualResult := resp.Header.Get("Location"); actualResult != "/path" {
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, "/path")
}
}
func TestHTMLRedirectAcceptedTargets(t *testing.T) {
scenarios := []string{
"/feeds",
"/category/1/entries",
"https://example.org/article",
"http://example.org/article",
}
for _, target := range scenarios {
t.Run(target, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
HTMLRedirect(w, r, target)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf(`Unexpected status code for %q, got %d instead of %d`, target, resp.StatusCode, http.StatusFound)
}
if actualResult := resp.Header.Get("Location"); actualResult != target {
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, target)
}
})
}
}
func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
scenarios := []string{
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"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",
"",
}
for _, target := range scenarios {
t.Run(target, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
HTMLRedirect(w, r, target)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(`Expected 400 for %q, got %d`, target, resp.StatusCode)
}
if location := resp.Header.Get("Location"); location != "" {
t.Fatalf(`Expected no Location header for %q, got %q`, target, location)
}
})
}
}
func TestHTMLRequestedRangeNotSatisfiable(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HTMLRequestedRangeNotSatisfiable(w, r, "bytes */12777")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusRequestedRangeNotSatisfiable)
}
if actualContentRangeHeader := resp.Header.Get("Content-Range"); actualContentRangeHeader != "bytes */12777" {
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, "bytes */12777")
}
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
)
const jsonContentTypeHeader = `application/json`
// JSON creates a new JSON response with a 200 status code.
func JSON(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
JSONServerError(w, r, err)
return
}
NewBuilder(w, r).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONCreated sends a created response to the client.
func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
JSONServerError(w, r, err)
return
}
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) {
NewBuilder(w, r).
WithStatus(http.StatusAccepted).
WithHeader("Content-Type", jsonContentTypeHeader).
Write()
}
// JSONServerError sends an internal error to the client.
func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONBadRequest sends a bad request error to the client.
func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONUnauthorized sends a not authorized error to the client.
func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusUnauthorized),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusUnauthorized),
),
)
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.
func JSONForbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
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.
func JSONNotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
Write()
}
func generateJSONError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
encodedBody, _ := json.Marshal(errorMsg{ErrorMessage: err.Error()})
return encodedBody
}
-215
View File
@@ -1,215 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/http/response/json"
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
)
const contentTypeHeader = `application/json`
// OK creates a new JSON response with a 200 status code.
func OK(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Created sends a created response to the client.
func Created(w http.ResponseWriter, r *http.Request, body any) {
responseBody, err := json.Marshal(body)
if err != nil {
ServerError(w, r, err)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusNoContent)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.Write()
}
func Accepted(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.Write()
}
// ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error(http.StatusText(http.StatusInternalServerError),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusInternalServerError),
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// BadRequest sends a bad request error to the client.
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
slog.Warn(http.StatusText(http.StatusBadRequest),
slog.Any("error", err),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusBadRequest),
),
)
responseBody, jsonErr := generateJSONError(err)
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusUnauthorized),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusUnauthorized),
),
)
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusForbidden),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusForbidden),
),
)
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
// NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) {
slog.Warn(http.StatusText(http.StatusNotFound),
slog.String("client_ip", request.ClientIP(r)),
slog.Group("request",
slog.String("method", r.Method),
slog.String("uri", r.RequestURI),
slog.String("user_agent", r.UserAgent()),
),
slog.Group("response",
slog.Int("status_code", http.StatusNotFound),
),
)
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
if jsonErr != nil {
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
builder := response.New(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", contentTypeHeader)
builder.WithBody(responseBody)
builder.Write()
}
func generateJSONError(err error) ([]byte, error) {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
if err != nil {
return nil, err
}
return encodedBody, nil
}
-312
View File
@@ -1,312 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/http/response/json"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestOKResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"key":"value"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestCreatedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Created(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusCreated
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"key":"value"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNoContentResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NoContent(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNoContent
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := ``
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("some error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"some error"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some Error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusBadRequest
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"Some Error"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestUnauthorizedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Unauthorized(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusUnauthorized
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"access unauthorized"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Forbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusForbidden
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"access forbidden"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusNotFound
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"resource not found"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
func TestBuildInvalidJSONResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, make(chan int))
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusInternalServerError
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := contentTypeHeader
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
}
}
+302
View File
@@ -0,0 +1,302 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestJSONResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSON(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
}
if actualBody := w.Body.String(); actualBody != `{"key":"value"}` {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{"key":"value"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONCreatedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONCreated(w, r, map[string]string{"key": "value"})
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusCreated)
}
if actualBody := w.Body.String(); actualBody != `{"key":"value"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"key":"value"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONAcceptedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONAccepted(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusAccepted {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusAccepted)
}
if actualBody := w.Body.String(); actualBody != `` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONServerErrorResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONServerError(w, r, errors.New("some error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"some error"}` {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{"error_message":"some error"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONBadRequestResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONBadRequest(w, r, errors.New("Some Error"))
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"Some Error"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"Some Error"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONUnauthorizedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONUnauthorized(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusUnauthorized)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"access unauthorized"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"access unauthorized"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONForbiddenResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONForbidden(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"access forbidden"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"access forbidden"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestJSONNotFoundResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONNotFound(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"resource not found"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"resource not found"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestBuildInvalidJSONResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSON(w, r, make(chan int))
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"json: unsupported type: chan int"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"json: unsupported type: chan int"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestBuildInvalidJSONCreatedResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
JSONCreated(w, r, make(chan int))
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
}
if actualBody := w.Body.String(); actualBody != `{"error_message":"json: unsupported type: chan int"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"json: unsupported type: chan int"}`)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
}
}
func TestGenerateJSONError(t *testing.T) {
actualBody := string(generateJSONError(errors.New("some error")))
if actualBody != `{"error_message":"some error"}` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"some error"}`)
}
}
+9
View File
@@ -3,6 +3,8 @@
package response // import "miniflux.app/v2/internal/http/response"
import "net/http"
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
// default-src 'none' disables all content sources
// form-action 'none' disables all form submissions
@@ -12,3 +14,10 @@ package response // import "miniflux.app/v2/internal/http/response"
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).
WithStatus(http.StatusNoContent).
Write()
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNoContentResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NoContent(w, r)
})
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNoContent)
}
if actualBody := w.Body.String(); actualBody != `` {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "" {
t.Fatalf(`Unexpected content type, got %q instead of empty string`, actualContentType)
}
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import "net/http"
// Text writes a standard text response with a status 200 OK.
func Text(w http.ResponseWriter, r *http.Request, body string) {
NewBuilder(w, r).
WithHeader("Content-Type", `text/plain; charset=utf-8`).
WithBodyAsString(body).
Write()
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTextResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Text(w, r, "Some plain text")
})
handler.ServeHTTP(w, r)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
}
if actualBody := w.Body.String(); actualBody != "Some plain text" {
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, "Some plain text")
}
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response // import "miniflux.app/v2/internal/http/response"
import "net/http"
// XML writes a standard XML response with a status 200 OK.
func XML(w http.ResponseWriter, r *http.Request, body string) {
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) {
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithAttachment(filename).
WithBodyAsString(body).
Write()
}
-27
View File
@@ -1,27 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package xml // import "miniflux.app/v2/internal/http/response/xml"
import (
"net/http"
"miniflux.app/v2/internal/http/response"
)
// OK writes a standard XML response with a status 200 OK.
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBody(body)
builder.Write()
}
// Attachment forces the XML document to be downloaded by the web browser.
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBody(body)
builder.Write()
}
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package xml // import "miniflux.app/v2/internal/http/response/xml"
package response // import "miniflux.app/v2/internal/http/response"
import (
"net/http"
@@ -9,7 +9,7 @@ import (
"testing"
)
func TestOKResponse(t *testing.T) {
func TestXMLResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
@@ -18,31 +18,26 @@ func TestOKResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
OK(w, r, "Some XML")
XML(w, r, "Some XML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
if resp.StatusCode != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
}
expectedBody := `Some XML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
if actualBody := w.Body.String(); actualBody != "Some XML" {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "Some XML")
}
expectedContentType := "text/xml; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/xml; charset=utf-8" {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/xml; charset=utf-8")
}
}
func TestAttachmentResponse(t *testing.T) {
func TestXMLAttachmentResponse(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
@@ -51,21 +46,18 @@ func TestAttachmentResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Attachment(w, r, "file.xml", "Some XML")
XMLAttachment(w, r, "file.xml", "Some XML")
})
handler.ServeHTTP(w, r)
resp := w.Result()
expectedStatusCode := http.StatusOK
if resp.StatusCode != expectedStatusCode {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
if resp.StatusCode != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
}
expectedBody := `Some XML`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
if actualBody := w.Body.String(); actualBody != "Some XML" {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "Some XML")
}
headers := map[string]string{
@@ -74,8 +66,7 @@ func TestAttachmentResponse(t *testing.T) {
}
for header, expected := range headers {
actual := resp.Header.Get(header)
if actual != expected {
if actual := resp.Header.Get(header); actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
-35
View File
@@ -1,35 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package route // import "miniflux.app/v2/internal/http/route"
import (
"strconv"
"github.com/gorilla/mux"
)
// Path returns the defined route based on given arguments.
func Path(router *mux.Router, name string, args ...any) string {
route := router.Get(name)
if route == nil {
panic("route not found: " + name)
}
var pairs []string
for _, arg := range args {
switch param := arg.(type) {
case string:
pairs = append(pairs, param)
case int64:
pairs = append(pairs, strconv.FormatInt(param, 10))
}
}
result, err := route.URLPath(pairs...)
if err != nil {
panic(err)
}
return result.String()
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"fmt"
"net/http"
"miniflux.app/v2/internal/storage"
)
func livenessProbe(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func newReadinessProbe(store *storage.Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
}
-321
View File
@@ -1,321 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"miniflux.app/v2/internal/api"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/fever"
"miniflux.app/v2/internal/googlereader"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui"
"miniflux.app/v2/internal/worker"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
listenAddresses := config.Opts.ListenAddr()
var httpServers []*http.Server
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
var sharedAutocertTLSConfig *tls.Config
if certDomain != "" {
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
sharedAutocertTLSConfig = &tls.Config{}
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.SetHTTPSValue(true)
httpServers = append(httpServers, challengeServer)
}
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
Handler: setupHandler(store, pool),
}
isUNIXSocket := strings.HasPrefix(listenAddr, "/")
isListenPID := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
if !isUNIXSocket && !isListenPID {
server.Addr = listenAddr
}
switch {
case isListenPID:
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.", slog.String("skipped_address", listenAddr))
continue
}
case isUNIXSocket:
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.SetHTTPSValue(true)
default:
server.Addr = listenAddr
startHTTPServer(server)
}
httpServers = append(httpServers, server)
}
return httpServers
}
func startSystemdSocketServer(server *http.Server) {
go func() {
f := os.NewFile(3, "systemd socket")
listener, err := net.FileListener(f)
if err != nil {
printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
}
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
go func() {
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
if certFile != "" && keyFile != "" {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
// Ensure HTTPS is marked as true if any listener uses TLS
config.Opts.SetHTTPSValue(true)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
} else {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}
}()
}
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startHTTPServer(server *http.Server) {
go func() {
slog.Info("Starting HTTP server",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
router := mux.NewRouter()
// These routes do not take the base path into consideration and are always available at the root of the server.
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
var subrouter *mux.Router
if config.Opts.BasePath() != "" {
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
} else {
subrouter = router.NewRoute().Subrouter()
}
if config.Opts.HasMaintenanceMode() {
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
subrouter.Use(middleware)
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
if config.Opts.HasAPI() {
api.Serve(subrouter, store, pool)
}
ui.Serve(subrouter, store, pool)
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
if config.Opts.HasMetricsCollector() {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
// Returns a 404 if the client is not authorized to access the metrics endpoint.
if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) {
slog.Warn("Authentication failed while accessing the metrics endpoint",
slog.String("client_ip", request.ClientIP(r)),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
})
}
return router
}
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
clientIP := request.ClientIP(r)
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
username, password, authOK := r.BasicAuth()
if !authOK {
slog.Warn("Metrics endpoint accessed without authentication header",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
if username == "" || password == "" {
slog.Warn("Metrics endpoint accessed with empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
}
remoteIP := request.FindRemoteIP(r)
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
}
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"log/slog"
"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"
)
func metricsHandler() http.Handler {
handler := promhttp.Handler()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAllowedToAccessMetricsEndpoint(r) {
slog.Warn("Authentication failed while accessing the metrics endpoint",
slog.String("client_ip", request.ClientIP(r)),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
http.NotFound(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
clientIP := request.ClientIP(r)
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
username, password, authOK := r.BasicAuth()
if !authOK {
slog.Warn("Metrics endpoint accessed without authentication header",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
if username == "" || password == "" {
slog.Warn("Metrics endpoint accessed with empty username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
// 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),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
)
return false
}
}
remoteIP := request.FindRemoteIP(r)
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"net/http"
"miniflux.app/v2/internal/api"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/fever"
"miniflux.app/v2/internal/googlereader"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui"
"miniflux.app/v2/internal/worker"
)
func newRouter(store *storage.Storage, pool *worker.Pool) http.Handler {
readinessProbe := newReadinessProbe(store)
// Application routes served under the base path.
appMux := http.NewServeMux()
appMux.HandleFunc("GET /healthcheck", readinessProbe)
// Fever API routing.
feverHandler := fever.Middleware(store)(fever.NewHandler(store))
appMux.Handle("/fever/", feverHandler)
// Google Reader API routing.
googleReaderHandler := googlereader.NewHandler(store)
appMux.HandleFunc("POST /accounts/ClientLogin", googleReaderHandler.ServeHTTP)
appMux.Handle("/reader/api/0/", googleReaderHandler)
// REST API routing.
if config.Opts.HasAPI() {
appMux.Handle("/v1/", api.NewHandler(store, pool))
}
// Metrics endpoint.
if config.Opts.HasMetricsCollector() {
appMux.Handle("GET /metrics", metricsHandler())
}
// UI routing (catch-all).
appMux.Handle("/", ui.Serve(store, pool))
// Apply shared middleware.
var appHandler http.Handler = appMux
if config.Opts.HasMaintenanceMode() {
appHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
}
appHandler = middleware(appHandler)
// Root router: health probes at root, app routes under base path.
rootMux := http.NewServeMux()
// These routes do not take the base path into consideration and are always available at the root of the server.
rootMux.HandleFunc("/liveness", livenessProbe)
rootMux.HandleFunc("/healthz", livenessProbe)
rootMux.HandleFunc("/readiness", readinessProbe)
rootMux.HandleFunc("/readyz", readinessProbe)
basePath := config.Opts.BasePath()
if basePath != "" {
rootMux.Handle(basePath+"/", http.StripPrefix(basePath, appHandler))
} else {
rootMux.Handle("/", appHandler)
}
return rootMux
}
+275
View File
@@ -0,0 +1,275 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/worker"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
var servers []*http.Server
autocertTLSConfig, challengeServer := setupAutocert(store)
if challengeServer != nil {
servers = append(servers, challengeServer)
}
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
targets := determineListenTargets(config.Opts.ListenAddr(), certDomain, certFile, keyFile)
if autocertTLSConfig != nil || anyTLS(targets) {
config.Opts.SetHTTPSValue(true)
}
for _, t := range targets {
srv := &http.Server{
Addr: t.address,
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
ReadHeaderTimeout: config.Opts.HTTPServerTimeout(),
Handler: newRouter(store, pool),
}
switch t.mode {
case modeSystemd:
startSystemdSocketServer(srv)
case modeUnixSocket:
startUnixSocketServer(srv, t.address)
case modeUnixSocketTLS:
startUnixSocketTLSServer(srv, t.address, t.certFile, t.keyFile)
case modeAutocertTLS:
startAutoCertTLSServer(srv, autocertTLSConfig)
case modeTLS:
startTLSServer(srv, t.certFile, t.keyFile)
default:
startHTTPServer(srv)
}
servers = append(servers, srv)
}
return servers
}
type listenerMode int
const (
modeHTTP listenerMode = iota
modeTLS
modeAutocertTLS
modeUnixSocket
modeUnixSocketTLS
modeSystemd
)
type listenTarget struct {
address string
mode listenerMode
certFile string
keyFile string
}
func determineListenTargets(addresses []string, certDomain, certFile, keyFile string) []listenTarget {
isSystemd := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
hasCertFiles := certFile != "" && keyFile != ""
hasAutocert := certDomain != ""
var targets []listenTarget
for i, addr := range addresses {
if isSystemd {
if i == 0 {
targets = append(targets, listenTarget{address: addr, mode: modeSystemd})
} else {
slog.Warn("Systemd socket activation: only the first listen address is used, others are ignored",
slog.String("skipped_address", addr),
)
}
continue
}
isUnix := strings.HasPrefix(addr, "/")
switch {
case isUnix && hasCertFiles:
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS, certFile: certFile, keyFile: keyFile})
case isUnix:
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocket})
case hasAutocert && (addr == ":https" || (i == 0 && strings.Contains(addr, ":"))):
targets = append(targets, listenTarget{address: addr, mode: modeAutocertTLS})
case hasCertFiles:
targets = append(targets, listenTarget{address: addr, mode: modeTLS, certFile: certFile, keyFile: keyFile})
default:
targets = append(targets, listenTarget{address: addr, mode: modeHTTP})
}
}
return targets
}
func anyTLS(targets []listenTarget) bool {
for _, t := range targets {
switch t.mode {
case modeTLS, modeAutocertTLS, modeUnixSocketTLS:
return true
}
}
return false
}
func setupAutocert(store *storage.Storage) (*tls.Config, *http.Server) {
certDomain := config.Opts.CertDomain()
if certDomain == "" {
return nil, nil
}
slog.Debug("Configuring autocert manager", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
tlsConfig := &tls.Config{
NextProtos: []string{"h2", "http/1.1", acme.ALPNProto},
}
tlsConfig.GetCertificate = certManager.GetCertificate
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
return tlsConfig, challengeServer
}
func startSystemdSocketServer(server *http.Server) {
go func() {
f := os.NewFile(3, "systemd socket")
listener, err := net.FileListener(f)
if err != nil {
printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
}
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
listener := createUnixSocketListener(socketFile)
go func() {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}()
}
func startUnixSocketTLSServer(server *http.Server, socketFile, certFile, keyFile string) {
listener := createUnixSocketListener(socketFile)
go func() {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
}()
}
func createUnixSocketListener(socketFile string) net.Listener {
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
return listener
}
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startHTTPServer(server *http.Server) {
go func() {
slog.Info("Starting HTTP server",
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}
+189
View File
@@ -0,0 +1,189 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"testing"
)
func TestDetermineListenTargets(t *testing.T) {
tests := []struct {
name string
addresses []string
certDomain string
certFile string
keyFile string
expected []listenTarget
}{
{
name: "single HTTP listener",
addresses: []string{":8080"},
expected: []listenTarget{
{address: ":8080", mode: modeHTTP},
},
},
{
name: "multiple HTTP listeners",
addresses: []string{":8080", ":9090"},
expected: []listenTarget{
{address: ":8080", mode: modeHTTP},
{address: ":9090", mode: modeHTTP},
},
},
{
name: "TLS with cert files",
addresses: []string{":443"},
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: ":443", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
},
},
{
name: "cert file without key file falls back to HTTP",
addresses: []string{":8080"},
certFile: "/path/to/cert.pem",
expected: []listenTarget{
{address: ":8080", mode: modeHTTP},
},
},
{
name: "key file without cert file falls back to HTTP",
addresses: []string{":8080"},
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: ":8080", mode: modeHTTP},
},
},
{
name: "autocert with :https address",
addresses: []string{":https"},
certDomain: "example.com",
expected: []listenTarget{
{address: ":https", mode: modeAutocertTLS},
},
},
{
name: "autocert with first address containing colon",
addresses: []string{":443"},
certDomain: "example.com",
expected: []listenTarget{
{address: ":443", mode: modeAutocertTLS},
},
},
{
name: "autocert does not apply to second non-https address",
addresses: []string{":https", ":8080"},
certDomain: "example.com",
expected: []listenTarget{
{address: ":https", mode: modeAutocertTLS},
{address: ":8080", mode: modeHTTP},
},
},
{
name: "unix socket",
addresses: []string{"/var/run/miniflux.sock"},
expected: []listenTarget{
{address: "/var/run/miniflux.sock", mode: modeUnixSocket},
},
},
{
name: "unix socket with TLS",
addresses: []string{"/var/run/miniflux.sock"},
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
},
},
{
name: "mixed unix socket and TCP",
addresses: []string{"/var/run/miniflux.sock", ":8080"},
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
{address: ":8080", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
},
},
{
name: "empty address list",
addresses: []string{},
expected: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := determineListenTargets(tc.addresses, tc.certDomain, tc.certFile, tc.keyFile)
if len(got) != len(tc.expected) {
t.Fatalf("got %d targets, want %d", len(got), len(tc.expected))
}
for i := range got {
if got[i] != tc.expected[i] {
t.Errorf("target[%d] = %+v, want %+v", i, got[i], tc.expected[i])
}
}
})
}
}
func TestAnyTLS(t *testing.T) {
tests := []struct {
name string
targets []listenTarget
expected bool
}{
{
name: "empty list",
targets: nil,
expected: false,
},
{
name: "HTTP only",
targets: []listenTarget{{mode: modeHTTP}},
expected: false,
},
{
name: "systemd only",
targets: []listenTarget{{mode: modeSystemd}},
expected: false,
},
{
name: "unix socket without TLS",
targets: []listenTarget{{mode: modeUnixSocket}},
expected: false,
},
{
name: "TLS mode",
targets: []listenTarget{{mode: modeTLS}},
expected: true,
},
{
name: "autocert TLS mode",
targets: []listenTarget{{mode: modeAutocertTLS}},
expected: true,
},
{
name: "unix socket TLS mode",
targets: []listenTarget{{mode: modeUnixSocketTLS}},
expected: true,
},
{
name: "mixed with one TLS",
targets: []listenTarget{{mode: modeHTTP}, {mode: modeTLS}, {mode: modeUnixSocket}},
expected: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := anyTLS(tc.targets); got != tc.expected {
t.Errorf("anyTLS() = %v, want %v", got, tc.expected)
}
})
}
}
+4 -2
View File
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -25,7 +27,7 @@ type Client struct {
}
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
return &Client{servicesURL: serviceURL, baseURL: baseURL}
}
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
@@ -65,7 +67,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
+28 -23
View File
@@ -4,11 +4,17 @@
package archiveorg
import (
"log/slog"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
@@ -18,26 +24,25 @@ func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
func (c *Client) SendURL(entryURL string) error {
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("archiveorg: unable to create request: %v", err)
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("archiveorg: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
}
return nil
}
+3 -2
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
return fmt.Errorf("betula: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("betula: unable to send request: %v", err)
+3 -1
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -77,7 +79,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
@@ -10,6 +10,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
}
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
+29 -24
View File
@@ -409,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.WebhookEnabled {
@@ -447,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
if err := client.SaveUrl(entry.URL); err != nil {
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
@@ -543,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Warn("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
@@ -642,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
client := pushover.NewClient(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
@@ -658,30 +665,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ type errorResponse struct {
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
@@ -56,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})
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
URL string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
+3 -1
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
+55 -31
View File
@@ -10,9 +10,13 @@ import (
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/config"
)
func TestCreateBookmark(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
tests := []struct {
name string
apiToken string
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "Invalid input",
},
},
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"errors": []any{
map[string]any{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
description := input["description"].(string)
if len(description) != maxDescriptionLength {
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
variables := req["variables"].(map[string]any)
input := variables["input"].(map[string]any)
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{"id": "123"},
},
})
},
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
}
func TestGraphQLMutation(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
variables, ok := req["variables"].(map[string]any)
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
input, ok := variables["input"].(map[string]any)
if !ok {
t.Fatal("Missing input in variables")
}
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"addLink": map[string]any{
"id": "123",
},
},
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
_ = strings.Join(splitTags, ",")
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)

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