Compare commits

...

267 Commits

Author SHA1 Message Date
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
Frédéric Guillot d3a1e7dbec ci: update Debian packager Docker image to Trixie 2026-02-12 19:59:43 -08:00
Frédéric Guillot 7edf42fe18 fix: run go fmt with Go 1.26 2026-02-12 19:22:47 -08:00
dependabot[bot] 5513827cdd build(deps): bump github.com/lib/pq from 1.11.1 to 1.11.2
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.11.1 to 1.11.2.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.11.1...v1.11.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-10 18:32:53 -08:00
dependabot[bot] 052ad27e38 build(deps): bump golang.org/x/net from 0.49.0 to 0.50.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:40:37 -08:00
dependabot[bot] 96c45874f5 build(deps): bump golang.org/x/crypto from 0.47.0 to 0.48.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:28:42 -08:00
dependabot[bot] 3e022fb86e build(deps): bump golang.org/x/image from 0.35.0 to 0.36.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/image/compare/v0.35.0...v0.36.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:21:08 -08:00
dependabot[bot] aa814491f6 build(deps): bump golang.org/x/oauth2 from 0.34.0 to 0.35.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:08:12 -08:00
dependabot[bot] 90c2199f0f build(deps): bump golang.org/x/term from 0.39.0 to 0.40.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/term/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 19:04:01 -08:00
Emiel Wiedijk d40e523d25 fix: 'v' shortcut uses main browser in Safari PWA
When using the 'v' shortcut in a Safari PWA on macOS, Miniflux opened
the link in something similar to an 'in-app browser' with no URL bar and
a separate window for each link. When clicking the link with the mouse,
a "normal" browser tab is opened instead.

It looks like target="_blank" on the <a> element implies the "noopener"
attribute, which causes Safari to use the main browser. For window.open,
"noopener" is not implied. This commit adds the "noreferrer" argument to
the window.open call (which implies the "noopener" option) rather than
changing the window.opener attribute manually. This option causes Safari
to use the main browser with the 'v' shortcut.
2026-02-09 16:09:09 -08:00
Frédéric Guillot 46e77eccb0 fix(request): change FindRemoteIP to fallback to 127.0.0.1 2026-02-08 19:39:26 -08:00
eyjhb f19fc2ff53 feat: add validation for TRUSTED_REVERSE_PROXY_NETWORKS config
Currently if the IP is not in CIDR notation it will just silently
fail, which can be very confusing. This commit changes that, as well
as adds a test.
2026-02-08 14:19:37 -08:00
jvoisin 0201c80db1 refactor(subscription): improve wp-json skip
No need for an extra-condition, we can simply express the constrain in the
query instead.
2026-02-08 14:00:11 -08:00
Frédéric Guillot 0022d0646b feat(subscription): ignore Wordpress API endpoint when discovering JSON feed 2026-02-06 16:56:28 -08:00
jvoisin 253119ed5f refactor(http): minor code cleanup
- Fix a grammar issue in the logs
- Factorize and name two checks
- Replace a variable with a `continue`
2026-02-06 16:31:58 -08:00
jvoisin 2619eba43c security(http): don't expose miniflux' version on an unauthenticated endpoint
There is no reason to expose miniflux' version to the internet.
2026-02-06 16:28:23 -08:00
Frédéric Guillot 31f4f366a9 fix(commit-checker): add security to conventional commit pattern 2026-02-06 16:23:59 -08:00
jvoisin fc9cb9e5d5 refactor(googlereader): unexport two symbols 2026-02-06 16:18:02 -08:00
jvoisin 9b859e7f3f refactor(storage): unexport a bunch of symbols 2026-02-06 16:17:18 -08:00
Julien Voisin 6f3c92db74 perf(subscription): don't read the body twice
Since FindSubscriptions is reading the Body in a []byte, there is no need to
wrap it into a Reader for NewCharsetReader to ReadAll into another buffer: we
can simply pass the []byte instead. For some large webpages, this might shave
off two allocation-deallocation of a handful of megabytes when looking for
subscriptions.
2026-02-06 16:15:51 -08:00
jvoisin 0dcc0a852b refactor(sanitizer): improve a bit sanitizeAttributes
- Sort blockedResourceURLSubstrings
- Add x.com to the list of blocked links
- Move isBlockedResource above ResolveToAbsoluteURLWithParsedBaseURL, to
  avoid having to properly parse blocked urls.
2026-02-05 21:48:21 -08:00
jvoisin 7b65255757 refactor(sanitizer): handle deeply nested recursion 2026-02-05 21:47:17 -08:00
jvoisin 6a9d7894d1 refactor(sanitizer): improve how attributes are sanitized
Move hasRequiredAttributes above getExtraAttributes, as extra attributes,
as their name implies, aren't required. This allows to return a []string
instead of a ([]string, []string), as well as simplifying getExtraAttributes
of course. Note that this resulted in an ordering change of <iframe> attributes
so the testsuite had to be updated.

This improves the performances of SanitizeHTML by a bit less than 10% on local
benchmarks.
2026-02-04 19:34:01 -08:00
jvoisin f3d0e0378b refactor(workflows): don't run useless on forks job forks
There is no need to build binaries and docker images, as well as mirroring to
codeberg, on fork repositories.
2026-02-02 15:48:36 -08:00
Frédéric Guillot 4d27f66ff9 refactor(sanitizer): reorder non-public functions alphabetically 2026-01-30 17:38:51 -08:00
Frédéric Guillot 5a8431b144 refactor(sanitizer): enforce isBlockedResource() on srcset URLs 2026-01-30 17:27:05 -08:00
Frédéric Guillot 9e760e14cd test(sanitizer): rewrite TestSelfClosingTags 2026-01-30 17:05:18 -08:00
Frédéric Guillot 879e40670c test: refactor TestImg and TestURIScheme 2026-01-30 16:57:39 -08:00
jvoisin bf24fd1ec9 perf(sanitizer): don't do a map lookup for every attribute of a tag
There is no need to do a lookup in allowedHTMLTagsAndAttributes in the loop,
as the tag never changes in this function. This is a minor optimization,
but since sanitizeAttributes is in the hot path of SanitizeHTML, it should be
worthwhile.
2026-01-30 16:40:57 -08:00
jvoisin b57ebac757 refactor(sanitizer): remove a useless case 2026-01-30 16:39:35 -08:00
Frédéric Guillot acfb7dc213 test: refactor TestAttrLowerCase 2026-01-30 16:39:23 -08:00
jvoisin 62f316c7cf refactor(sanitizer): html attributes keys are always lowercase 2026-01-30 16:33:21 -08:00
Frédéric Guillot 89638b448a refactor(sanitizer): remove workaround that strip large img width attribute
The browser will do the right thing by default even if the image width
is larger than Miniflux's layout.
2026-01-29 21:24:15 -08:00
jvoisin d8181b1f65 refactor(sanitizer): use a parser instead of a tokenizer
As stated in [golang.org/x/net/html's documentation](https://pkg.go.dev/golang.org/x/net):

> If your use case requires semantically well-formed HTML documents, as defined
by the WHATWG specification, the parser should be used rather than the
tokenizer.

The sanitizer is modifying the HTML document, and I'm pretty sure we don't want
to implement a WHATWG-compliant parser ourself, as there are _so many_
edge cases everywhere. Picking the parser instead of the tokenizer ensures that
miniflux has the same view of the DOM than web browsers, removing the risk of
disparities, and thus injection-related security issues. Another benefit of
this commit is to make the code way more readable/simple.

The only changes made to the testsuites are:

- Removing the trailing `/` on self-contained tags, as the spec doesn't require
  them. Adding them systematically could also have been done, but as the
  testsuite isn't consistent in this regard, it would have significantly
  increased the size of the commit to normalize it.
- Some weird things that were wrong in the first place, like how
  `<td rowspan="<b>injection</b>">text</td>` is interpreted by the browser as
  `text`, and not as `<td rowspan="&lt;b&gt;test&lt;/b&gt;">test</td>`.

Care has been taken to re-use as much code from the tokenizer-based sanitizer
as possible.
2026-01-29 20:36:09 -08:00
Bùi Minh Đức ce6cd0b2b6 fix(rewrite): update rewrite rules for vnexpress.net 2026-01-29 20:09:46 -08:00
Bùi Minh Đức ba05c96d88 feat(rewrite): add rewrite rules for vnexpress.net
Add lazy loading images, remove embedded related news articles, surveys, and fix video display
2026-01-29 20:09:46 -08:00
Bùi Minh Đức 58674b9277 feat(scraper): add scraper rule for vnexpress.net 2026-01-29 20:09:46 -08:00
dependabot[bot] 6b1f208209 build(deps): bump github.com/lib/pq from 1.10.9 to 1.11.1
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.10.9 to 1.11.1.
- [Release notes](https://github.com/lib/pq/releases)
- [Changelog](https://github.com/lib/pq/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lib/pq/compare/v1.10.9...v1.11.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-29 19:29:49 -08:00
Julien Voisin 7c41128d88 refactor(sanitizer): use uint instead of int when possible
This helps with bound-check elimination, and makes the code a bit more clear.
2026-01-28 19:46:59 -08:00
Frédéric Guillot 3aea68e725 refactor(sanitizer): rewrite srcset parser according to HTML specs
The new implementation follows the WebKit HTMLSrcsetParser approach. It
correctly handles various edge cases that the previous string-splitting
method could not parse.
2026-01-27 20:06:45 -08:00
jvoisin a5fb91e3e8 refactor(sanitizer): rename isValidTag to isAllowedTag 2026-01-26 20:21:08 -08:00
jvoisin 8357e6decf refactor(sanitizer): don't process tags when len(blockedStack) != 0
Then there are things in blockedStack, it means that we're currently iterating
on children of a blocked tag, so there is no need to actually process them.
2026-01-26 20:21:08 -08:00
Bùi Minh Đức ad4ca71f68 feat(scraper): add scraper rule for bleepingcomputer.com 2026-01-26 15:45:28 -08:00
Bùi Minh Đức 660c79bbdd feat(rewrite): add rewrite rules for bleepingcomputer.com
Remove embedded in-article advertisements, links to other articles, and add lazy loading images
2026-01-26 15:45:28 -08:00
Frédéric Guillot 2fa8995a35 fix: avoid possible deadlock when cleaning removed entries
- Lock delete targets in order with SKIP LOCKED before deleting removed entries
- Have the removed-entry scrubber skip locked rows to prevent blocking
2026-01-19 12:04:57 -08:00
Frédéric Guillot ffed2b4fa1 fix(storage): do not keep any old enclosures if there is none in the updated entry 2026-01-19 11:24:54 -08:00
Frédéric Guillot 9fc69fd2e0 refactor(storage): update IconByFeedID to handle sql.ErrNoRows 2026-01-19 10:47:18 -08:00
Frédéric Guillot cddd2eb168 feat(jsonfeed): include external_url in JSON entry hash fallback 2026-01-18 17:41:47 -08:00
Frédéric Guillot 33b780d9c0 fix(jsonfeed): stop the title fallback loop at the first non-empty value 2026-01-18 17:29:43 -08:00
Frédéric Guillot 5d8540b324 fix(jsonfeed): avoid panic when parsing null JSON feed 2026-01-18 17:16:15 -08:00
Frédéric Guillot a2a5244387 feat(jsonfeed): support malformed feeds with author object in authors array 2026-01-18 17:01:55 -08:00
Frédéric Guillot 3a232d0c8d test(request): add 100% unit test coverage 2026-01-16 17:23:20 -08:00
Frédéric Guillot 5a8df8abf0 fix(fetcher): ensure response body is closed even on client error 2026-01-16 17:02:32 -08:00
Julien Voisin a4883ca11e refactor(fetcher): use modern string functions and improve error messages 2026-01-16 15:35:44 -08:00
Frédéric Guillot 4cd66aef5f docs(client): improve Godoc comments for exported functions 2026-01-16 14:31:38 -08:00
Frédéric Guillot 0ab3bb5efb refactor(api): replace inlined map and structs with named payload structs 2026-01-16 14:20:37 -08:00
Frédéric Guillot 71c551ffe0 test(timezone): add test for AvailableTimezones() 2026-01-16 13:59:09 -08:00
Frédéric Guillot bb05b25530 refactor(urllib): replace AbsoluteURL and GetAbsoluteURL 2026-01-16 13:50:54 -08:00
Frédéric Guillot 7b7fd48e15 test(validator): add more unit tests for the validation functions 2026-01-16 11:43:03 -08:00
Frédéric Guillot fb0fcfb266 fix(js): use arrow functions for touchcancel event to maintain context 2026-01-15 21:33:22 -08:00
Frédéric Guillot 39840bb0d2 refactor(js): avoid returning values from forEach callbacks to prevent ignored results 2026-01-15 21:20:00 -08:00
Frédéric Guillot 79a3369699 refactor(js): use template literals for string concatenation to improve readability 2026-01-15 21:01:48 -08:00
Frédéric Guillot 696b98bb16 refactor(js): store keyboard shortcuts in a Map instead of plain object 2026-01-15 20:44:29 -08:00
Márton Salomváry 85fa69ba38 fix: unbreak cmd/ctrl/shift click on main nav
Most browsers allow opening links in a new window or new
tab by holding down a modifier key like shift, command,
control (depending on the OS and browser) while clicking a link.

Unconditional event.preventDefault() on click events breaks this functionality.
2026-01-15 19:38:56 -08:00
jvoisin c5a9111d58 refactor(timezone): improve internal timezones handling
- Add a new IsValid method to avoid having to materialize a map just to check
  if a given timezone is in it.
- Use an iterator instead of materializing a map every time the full list of
  timezones is required.
- Use a slice in the template instead of a map to iterate over all timezones.
2026-01-12 20:40:10 -08:00
dependabot[bot] a46afe6979 build(deps): bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:34:56 -08:00
dependabot[bot] 2f67a68e75 build(deps): bump golang.org/x/crypto from 0.46.0 to 0.47.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.46.0 to 0.47.0.
- [Commits](https://github.com/golang/crypto/compare/v0.46.0...v0.47.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:21:01 -08:00
dependabot[bot] 83ca211cfb build(deps): bump golang.org/x/image from 0.34.0 to 0.35.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/image/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 19:10:53 -08:00
dependabot[bot] 55aa15dd52 build(deps): bump golang.org/x/term from 0.38.0 to 0.39.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/term/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 17:37:45 -08:00
Frédéric Guillot e54e6c097c feat(config)!: deprecate FILTER_ENTRY_MAX_AGE_DAYS config option
This option can be replaced with a filter rule `max-age:<duration>` instead.

Global environment variables should be reserved for the Miniflux process
configuration that are not meant to be modified by end users.
2026-01-08 20:28:01 -08:00
Frédéric Guillot 56a11d1d45 chore: remove prealloc linter 2026-01-07 20:24:43 -08:00
lclee3390 df28028b24 feat(ui): add filter to search results by unread status 2026-01-07 20:13:56 -08:00
Frédéric Guillot 6439d352d2 docs(man): improve miniflux.1 consistency and fix typos 2026-01-05 18:55:33 -08:00
Frédéric Guillot a33cfb2b7d feat(locale): update translations 2026-01-05 17:58:19 -08:00
Frédéric Guillot 590adb9967 refactor(googlereader): remove output param check for user-info handler
The `output` parameter seems to be optional and Miniflux will always
returns a JSON response. This change makes Miniflux more consistent with
other open source RSS readers.
2026-01-05 16:01:01 -08:00
Frédéric Guillot 9e817e646f feat(googlereader)!: remove CORS handler
The Google Reader API is not supposed to be used by web clients.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 18:10:02 -07:00
384 changed files with 15345 additions and 7358 deletions
+4 -2
View File
@@ -9,7 +9,9 @@
],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"customizations": {
"vscode": {
@@ -28,4 +30,4 @@
]
}
}
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go:1.23
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
volumes:
- ..:/workspace:cached
command: sleep infinity
@@ -11,10 +10,10 @@ services:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:15
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
hostname: postgres
environment:
POSTGRES_DB: miniflux2
+5 -2
View File
@@ -1,4 +1,6 @@
name: Build Binaries
permissions:
contents: read
on:
workflow_dispatch:
push:
@@ -7,10 +9,11 @@ on:
jobs:
build:
name: Build
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Golang
uses: actions/setup-go@v6
with:
@@ -21,7 +24,7 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: binaries
path: miniflux-*
+26
View File
@@ -0,0 +1,26 @@
name: Mirror to Codeberg
on:
push:
branches: [ main ]
delete:
workflow_dispatch:
jobs:
mirror:
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Mirror to Codeberg
env:
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
run: |
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
git push --force --prune codeberg \
"refs/heads/*:refs/heads/*" \
"refs/tags/*:refs/tags/*"
+12 -2
View File
@@ -9,6 +9,7 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
@@ -16,12 +17,14 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '45 22 * * 3'
workflow_dispatch:
jobs:
analyze:
name: Analyze
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
@@ -30,20 +33,27 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: actions/setup-go@v6
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+13 -12
View File
@@ -13,17 +13,18 @@ on:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
jobs:
test-packages:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -38,13 +39,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
@@ -53,24 +54,24 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.deb"
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@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
id: buildx
with:
install: true
+11 -10
View File
@@ -12,18 +12,19 @@ on:
jobs:
docker-images:
name: Docker Images
if: github.repository_owner == 'miniflux'
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -36,7 +37,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -50,21 +51,21 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -72,14 +73,14 @@ jobs:
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
@@ -89,7 +90,7 @@ jobs:
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
with:
context: .
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: stable
- uses: golangci/golangci-lint-action@v8
- uses: golangci/golangci-lint-action@v9
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,7 +38,7 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
+9 -7
View File
@@ -11,17 +11,19 @@ on:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
run: make rpm VERSION=2.2.x_dev
- name: List generated files
run: ls -l *.rpm
build-package-manually:
@@ -29,24 +31,24 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: packages
path: "*.rpm"
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@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Match
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
@@ -44,7 +44,7 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
-1
View File
@@ -10,7 +10,6 @@ linters:
- loggercheck
- misspell
- perfsprint
- prealloc
- sqlclosecheck
- staticcheck
- whitespace
+3 -3
View File
@@ -16,12 +16,10 @@ export PGPASSWORD := postgres
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-x86 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
openbsd-amd64 \
netbsd-amd64 \
build \
run \
clean \
@@ -100,7 +98,7 @@ test:
lint:
go vet ./...
gofmt -d -e .
test -z "$$(gofmt -l .)"
golangci-lint run
integration-test:
@@ -113,6 +111,8 @@ integration-test:
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
+1 -1
View File
@@ -3,7 +3,7 @@ Miniflux API Client
[![PkgGoDev](https://pkg.go.dev/badge/miniflux.app/v2/client)](https://pkg.go.dev/miniflux.app/v2/client)
Client library for Miniflux REST API.
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
Installation
------------
+42 -14
View File
@@ -22,6 +22,8 @@ type Client struct {
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
//
//go:fix inline
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
}
@@ -334,14 +336,14 @@ func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
// IntegrationsStatus fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatus() (bool, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IntegrationsStatusContext(ctx)
}
// IntegrationsStatusContext fetches the integrations status for the logged user.
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
body, err := c.request.Get(ctx, "/v1/integrations/status")
if err != nil {
@@ -360,7 +362,7 @@ func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
return response.HasIntegrations, nil
}
// Discover try to find subscriptions from a website.
// Discover tries to find subscriptions on a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -383,14 +385,14 @@ func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions
return subscriptions, nil
}
// Categories gets the list of categories.
// Categories retrieves the list of categories.
func (c *Client) Categories() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesContext(ctx)
}
// CategoriesContext gets the list of categories.
// CategoriesContext retrieves the list of categories.
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories")
if err != nil {
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
Title: new(title),
})
if err != nil {
return nil, err
@@ -537,14 +539,14 @@ func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64
return err
}
// CategoryFeeds gets feeds of a category.
// CategoryFeeds returns all feeds for a category.
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryFeedsContext(ctx, categoryID)
}
// CategoryFeedsContext gets feeds of a category.
// CategoryFeedsContext returns all feeds for a category.
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
if err != nil {
@@ -608,14 +610,14 @@ func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
return feeds, nil
}
// Export creates OPML file.
// Export exports subscriptions as an OPML document.
func (c *Client) Export() ([]byte, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ExportContext(ctx)
}
// ExportContext creates OPML file.
// ExportContext exports subscriptions as an OPML document.
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
body, err := c.request.Get(ctx, "/v1/export")
if err != nil {
@@ -717,6 +719,32 @@ func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChange
return f, nil
}
// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()
var response struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}
return response.ID, nil
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
ctx, cancel := withDefaultTimeout()
@@ -860,7 +888,7 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}
// Entries fetch entries.
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -885,7 +913,7 @@ func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResu
return &result, nil
}
// FeedEntries fetch feed entries.
// FeedEntries fetches entries for a feed using the given filter.
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -910,7 +938,7 @@ func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *F
return &result, nil
}
// CategoryEntries fetch entries of a category.
// CategoryEntries fetches entries for a category using the given filter.
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
@@ -976,7 +1004,7 @@ func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryCha
return entry, nil
}
// ToggleStarred toggles entry starred value.
// ToggleStarred toggles the starred flag of an entry.
func (c *Client) ToggleStarred(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
+2 -2
View File
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
# Examples
This code snippet fetch the list of users:
This example fetches the list of users:
import (
miniflux "miniflux.app/v2/client"
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
}
fmt.Println(users, err)
This one discover subscriptions on a website:
This example discovers subscriptions on a website:
subscriptions, err := client.Discover("https://example.org/")
if err != nil {
+8 -2
View File
@@ -149,7 +149,7 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CheckedAt time.Time `json:"checked_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
@@ -166,6 +166,7 @@ type Feed struct {
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
@@ -185,6 +186,7 @@ type FeedCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
@@ -214,6 +216,7 @@ type FeedModificationRequest struct {
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
@@ -356,6 +359,9 @@ type APIKeyCreationRequest struct {
Description string `json:"description"`
}
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
//
//go:fix inline
func SetOptionalField[T any](value T) *T {
return &value
return new(value)
}
+1 -1
View File
@@ -26,7 +26,7 @@ services:
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+1 -1
View File
@@ -31,7 +31,7 @@ services:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+1 -1
View File
@@ -43,7 +43,7 @@ services:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+24 -25
View File
@@ -1,50 +1,49 @@
module miniflux.app/v2
// +heroku goVersion go1.24
// +heroku goVersion go1.26
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/go-webauthn/webauthn v0.14.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.16.2
github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.4
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
golang.org/x/term v0.36.0
github.com/tdewolff/minify/v2 v2.24.11
golang.org/x/crypto v0.49.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
)
require (
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
require (
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/fxamacker/cbor/v2 v2.9.1 // 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/mitchellh/mapstructure v1.5.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.4 // indirect
github.com/tdewolff/parse/v2 v2.8.11 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.24.0
toolchain go1.24.1
go 1.26.0
+46 -42
View File
@@ -1,37 +1,39 @@
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/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.16.2 h1:n116UuvIa7nUVGFP2hO9U24gBqhJTcmbU3ph0wgVzFM=
github.com/go-webauthn/webauthn v0.16.2/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
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=
@@ -40,12 +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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/lib/pq v1.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,14 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.4 h1:pQyr6eWDa+RXtAoZg+6wurh0jB9ojqw/qc5LlU7/z6c=
github.com/tdewolff/minify/v2 v2.24.4/go.mod h1:iD9Qn7/brhKY9d0KLKMkZrqS8/bqxSxRKruBi7V6m+w=
github.com/tdewolff/parse/v2 v2.8.4 h1:A6slgBLGGDPBMGA28KQZfHpaKffuNvhOe7zSag+x/rw=
github.com/tdewolff/parse/v2 v2.8.4/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.11 h1:JlANsiWaRBXedoYtsiZgY3YFkdr42oF32vp2SLgQKi4=
github.com/tdewolff/minify/v2 v2.24.11/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/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 +87,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -101,10 +105,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -123,8 +127,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -134,8 +138,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -145,8 +149,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+59 -77
View File
@@ -5,91 +5,73 @@ 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/{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", handler.getEntriesHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
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)))
}
+68 -15
View File
@@ -14,6 +14,7 @@ import (
"testing"
miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
@@ -579,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -608,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
ExternalFontHosts: new(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -637,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
ExternalFontHosts: new("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
@@ -661,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
CustomJS: new("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -690,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("invalid_theme"),
Theme: new("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
@@ -720,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: miniflux.SetOptionalField("dark_serif"),
Theme: new("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
@@ -1089,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
Title: new("new title"),
})
if err != nil {
t.Fatal(err)
@@ -1108,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
HideGlobally: new(true),
})
if err != nil {
t.Fatal(err)
@@ -1127,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
HideGlobally: new(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
@@ -1611,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
FeedURL: new("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
@@ -1652,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
FeedURL: new(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -1684,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: miniflux.SetOptionalField(int64(123456789)),
CategoryID: new(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
@@ -2717,8 +2718,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: miniflux.SetOptionalField("New title"),
Content: miniflux.SetOptionalField("New content"),
Title: new("New title"),
Content: new("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
@@ -2932,3 +2933,55 @@ func TestFlushHistoryEndpoint(t *testing.T) {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}
func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)
// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)
payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}
// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}
// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
@@ -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)
}
+118
View File
@@ -0,0 +1,118 @@
// 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 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,100 +5,111 @@ 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")
@@ -110,32 +121,42 @@ func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
}
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())
@@ -147,7 +168,7 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
jobs, err := batchBuilder.FetchJobs()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -160,5 +181,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,85 @@ 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)
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)
response.JSONNotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
enclosure.ProxifyEnclosureURL(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)
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)
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)
}
@@ -11,13 +11,15 @@ import (
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/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"
)
@@ -25,24 +27,33 @@ import (
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)
response.JSONServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)
entry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, entry)
response.JSON(w, r, entry)
}
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
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))
builder.WithFeedID(feedID)
@@ -52,9 +63,18 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
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))
builder.WithCategoryID(categoryID)
@@ -64,8 +84,13 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
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))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
@@ -73,17 +98,26 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
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) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
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) getEntries(w http.ResponseWriter, r *http.Request) {
func (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {
h.findEntries(w, r, 0, 0)
}
@@ -91,40 +125,40 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
statuses := request.QueryStringParamList(r, "status")
for _, status := range statuses {
if err := validator.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
}
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := validator.ValidateEntryOrder(order); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := validator.ValidateDirection(direction); err != nil {
json.BadRequest(w, r, err)
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 {
json.BadRequest(w, r, err)
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) {
json.BadRequest(w, r, errors.New("invalid category ID"))
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) {
json.BadRequest(w, r, errors.New("invalid feed ID"))
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
@@ -151,145 +185,267 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
configureFilters(builder, r)
entries, err := builder.GetEntries()
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
json.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
func (h *handler) setEntryStatusHandler(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)
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.NoContent(w, r)
response.NoContent(w, r)
}
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
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
}
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"))
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
settings, err := h.store.Integration(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
go integration.SendEntry(entry, settings)
json.Accepted(w, r)
response.JSONAccepted(w, r)
}
func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
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 {
json.BadRequest(w, r, err)
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
json.BadRequest(w, r, err)
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)
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)
response.JSONServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
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 {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
json.Created(w, r, entry)
response.JSONCreated(w, r, entry)
}
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
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 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
}
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
@@ -297,23 +453,23 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
entry, err := entryBuilder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
response.JSONNotFound(w, r)
return
}
@@ -321,39 +477,35 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
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
}
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
json.ServerError(w, r, err)
response.JSONServerError(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)
response.JSONServerError(w, r, err)
return
}
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
return
}
json.OK(w, r, map[string]string{"content": entry.Content})
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
json.Accepted(w, r)
response.JSONAccepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
@@ -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,45 +31,49 @@ 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()
@@ -80,7 +85,7 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
jobs, err := batchBuilder.FetchJobs()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -92,141 +97,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.JSONNotFound(w, r)
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,53 +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 !h.store.HasFeedIcon(feedID) {
json.NotFound(w, r)
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(),
@@ -18,10 +18,40 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
type integrationsStatusResponse struct {
HasIntegrations bool `json:"has_integrations"`
}
type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
}
type entryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
type importFeedsResponse struct {
Message string `json:"message"`
}
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
+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, map[string]string{"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
}
@@ -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)
}
-222
View File
@@ -1,222 +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)
response := struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: hasIntegrations,
}
json.OK(w, r, response)
}
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)
}
+247
View File
@@ -0,0 +1,247 @@
// 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"
"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
}
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
}
if _, err := h.store.UserByID(userID); err != 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)
if _, err := h.store.UserByID(userID); err != 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.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
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.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
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
}
h.store.RemoveUserAsync(user.ID)
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 {
printErrorAndExit(fmt.Errorf("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 {
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
}
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
+1 -1
View File
@@ -47,7 +47,7 @@ func runCleanupTasks(store *storage.Storage) {
}
}
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
+14 -28
View File
@@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"flag"
"fmt"
"io"
@@ -30,9 +29,9 @@ const (
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
@@ -92,21 +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 err := config.Opts.Validate(); err != nil {
printErrorAndExit(err)
}
if flagConfigDump {
@@ -114,6 +100,16 @@ func Parse() {
return
}
if flagInfo {
info()
return
}
if flagVersion {
fmt.Println(version.Version)
return
}
if flagDebugMode {
config.Opts.SetLogLevel("debug")
}
@@ -142,16 +138,6 @@ 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")
}
+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")
}
+450 -411
View File
File diff suppressed because it is too large Load Diff
+378 -21
View File
@@ -3,7 +3,10 @@
package config // import "miniflux.app/v2/internal/config"
import "testing"
import (
"slices"
"testing"
)
func TestBaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1270,22 +1273,6 @@ func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
}
}
func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FilterEntryMaxAgeDays() != 0 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
}
if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FilterEntryMaxAgeDays() != 7 {
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
}
}
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1364,6 +1351,54 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
}
}
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1584,6 +1619,37 @@ func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
}
}
func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(defaultNetworks) != 0 {
t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
}
// Test valid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(allowedNetworks) != 2 {
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
}
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
}
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
}
// Test invalid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
}
}
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
configParser := NewConfigParser()
@@ -1614,8 +1680,8 @@ func TestSetLogLevelFunction(t *testing.T) {
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].RawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
if configParser.options.options["LOG_LEVEL"].rawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
// Test setting log level to warning
@@ -1623,8 +1689,8 @@ func TestSetLogLevelFunction(t *testing.T) {
if configParser.options.LogLevel() != "warning" {
t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].RawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
if configParser.options.options["LOG_LEVEL"].rawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
}
@@ -1686,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.Fatal("Expected error when local auth is disabled with OAuth2 but without user creation")
}
}
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.Fatal("Expected error when local auth is disabled with auth proxy but without user creation")
}
}
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)
}
}
+91 -39
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@@ -49,9 +50,57 @@ 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() {
switch {
case 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")
case c.OAuth2Provider() != "" && !c.IsOAuth2UserCreationAllowed():
return errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled")
case c.AuthProxyHeader() != "" && !c.IsAuthProxyUserCreationAllowed():
return errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled")
}
}
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
baseURL := cp.options.options["BASE_URL"].parsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
parsedURL, err := url.Parse(baseURL)
@@ -64,14 +113,14 @@ func (cp *configParser) postParsing() error {
return errors.New("BASE_URL scheme must be http or https")
}
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
cp.options.options["BASE_URL"].parsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
@@ -81,16 +130,16 @@ func (cp *configParser) postParsing() error {
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
}
return nil
@@ -119,73 +168,76 @@ func (cp *configParser) parseLines(lines []string) error {
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
}
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
// Validate the option if a validator is provided
if field.Validator != nil {
if err := field.Validator(value); err != nil {
if field.validator != nil {
if err := field.validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.ValueType {
switch field.valueType {
case stringType:
field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
field.RawValue = value
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
field.rawValue = value
case stringListType:
field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
field.RawValue = value
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
field.rawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.ParsedBoolValue = parsedValue
field.RawValue = value
field.parsedBoolValue = parsedValue
field.rawValue = value
case intType:
field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
field.RawValue = value
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
field.rawValue = value
case int64Type:
field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
field.RawValue = value
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
field.rawValue = value
case secondType:
field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
field.rawValue = value
case minuteType:
field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
field.rawValue = value
case hourType:
field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
field.rawValue = value
case dayType:
field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
field.RawValue = value
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
field.rawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.ParsedURLValue)
parsedURL, err := parseURLValue(value, field.parsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.ParsedURLValue = parsedURL
field.RawValue = value
field.parsedURLValue = parsedURL
field.rawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.TargetKey != "" {
if targetField, ok := cp.options.options[field.TargetKey]; ok {
targetField.ParsedStringValue = secretValue
targetField.RawValue = secretValue
if field.targetKey != "" {
if targetField, ok := cp.options.options[field.targetKey]; ok {
targetField.parsedStringValue = secretValue
targetField.rawValue = secretValue
}
}
field.RawValue = value
field.rawValue = value
case bytesType:
if value != "" {
field.ParsedBytesValue = []byte(value)
field.RawValue = value
field.parsedBytesValue = []byte(value)
field.rawValue = value
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ func validateChoices(rawValue string, choices []string) error {
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if !slices.Contains(choices, value) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
+59 -1
View File
@@ -435,7 +435,7 @@ var migrations = [...]func(tx *sql.Tx) error{
hasExtra := false
if err := tx.QueryRow(`
SELECT true
SELECT true
FROM information_schema.columns
WHERE
table_name='users' AND
@@ -1373,4 +1373,62 @@ var migrations = [...]func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// There is no need to keep an index on the content of deleted entries.
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
if err != nil {
return err
}
sql := `
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors)
WHERE status != 'removed';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`
ALTER TABLE user_sessions
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
ALTER COLUMN ip SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN user_agent SET DEFAULT '',
ALTER COLUMN user_agent SET NOT NULL
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
}
+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'
```
+68 -84
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,7 +233,7 @@ 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)
@@ -279,7 +273,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
if csvItemIDs != "" {
var itemIDs []int64
for _, strItemID := range strings.Split(csvItemIDs, ",") {
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
strItemID = strings.TrimSpace(strItemID)
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
itemIDs = append(itemIDs, itemID)
@@ -295,7 +289,7 @@ 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
}
@@ -303,7 +297,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -324,7 +318,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 +327,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
}
result.SetCommonValues()
json.OK(w, r, result)
response.JSON(w, r, result)
}
/*
@@ -344,7 +338,7 @@ 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),
@@ -354,7 +348,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -366,7 +360,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,7 +371,7 @@ 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),
@@ -388,7 +382,7 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
entryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -399,7 +393,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 +401,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),
@@ -424,7 +418,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, r, err)
response.JSONServerError(w, r, err)
return
}
@@ -433,7 +427,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 +450,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 +469,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 +483,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 +498,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 +512,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 +551,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
+20 -32
View File
@@ -6,39 +6,27 @@ 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) handleCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
@@ -51,7 +39,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
@@ -62,7 +50,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
} else {
@@ -74,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
fields := strings.Fields(authorization)
@@ -84,7 +72,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
if fields[0] != "GoogleLogin" {
@@ -93,7 +81,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
auths := strings.Split(fields[1], "=")
@@ -103,7 +91,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
if auths[0] != "auth" {
@@ -112,7 +100,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
token = auths[1]
@@ -126,7 +114,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
var integration *model.Integration
@@ -139,17 +127,17 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if expectedToken != token {
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)
sendUnauthorizedResponse(w, r)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
@@ -159,7 +147,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
@@ -169,7 +157,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w)
sendUnauthorizedResponse(w, r)
return
}
@@ -188,7 +176,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
}
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
}
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
type requestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
@@ -24,7 +24,7 @@ type RequestModifiers struct {
UserID int64
}
func (r RequestModifiers) String() string {
func (r requestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
@@ -57,9 +57,9 @@ func (r RequestModifiers) String() string {
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
func parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
result := requestModifiers{
SortDirection: "desc",
UserID: userID,
}
@@ -71,16 +71,16 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
return requestModifiers{}, err
}
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
+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) {
builder := response.NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithBodyAsString("Unauthorized")
builder.Write()
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/internal/http/client"
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
)
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
// Options holds configuration for creating an HTTP client.
type Options struct {
Timeout time.Duration
BlockPrivateNetworks bool
}
// NewClientWithOptions creates a new HTTP client with the specified options.
func NewClientWithOptions(opts Options) *http.Client {
if !opts.BlockPrivateNetworks {
return &http.Client{Timeout: opts.Timeout}
}
dialer := &net.Dialer{
Timeout: opts.Timeout,
}
transport := &http.Transport{
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
}
var safeIP net.IP
for _, ip := range ips {
if !urllib.IsNonPublicIP(ip) {
safeIP = ip
break
}
}
if safeIP == nil {
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
}
safeAddr := net.JoinHostPort(safeIP.String(), port)
return dialer.DialContext(ctx, network, safeAddr)
},
}
return &http.Client{
Timeout: opts.Timeout,
Transport: transport,
}
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client
import (
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err := client.Get(server.URL)
if err == nil {
t.Fatal("Expected an error when connecting to loopback address, got nil")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
}
}
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
if client == nil {
t.Fatal("Expected non-nil client")
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
}
if transport.DialContext == nil {
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
}
}
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
if client.Transport != nil {
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
}
}
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.Listener = listener
server.Start()
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
_, err = client.Get(server.URL)
if err == nil {
t.Fatal("Expected error when connecting to private IP")
}
if !errors.Is(err, ErrPrivateNetwork) {
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
}
}
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
+62 -19
View File
@@ -9,19 +9,46 @@ import (
"strings"
)
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
if len(trustedNetworks) == 0 {
return false
}
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
ip := net.ParseIP(remoteIP)
if ip == nil {
return false
}
if net.ParseIP(address) != nil {
return address
for _, cidr := range trustedNetworks {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
if isTrustedProxyClient {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
if net.ParseIP(address) != nil {
return address
}
}
}
}
@@ -30,19 +57,35 @@ func FindClientIP(r *http.Request) string {
return FindRemoteIP(r)
}
// FindRemoteIP returns remote client IP address without considering HTTP headers.
// FindRemoteIP returns the parsed remote IP address from the request,
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
func FindRemoteIP(r *http.Request) string {
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteIP = r.RemoteAddr
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
return "127.0.0.1"
}
return dropIPv6zone(remoteIP)
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
ip = r.RemoteAddr
}
// Strip IPv6 zone identifier if present (e.g., %eth0).
ip = dropIPv6zone(ip)
// Validate the IP address.
if net.ParseIP(ip) == nil {
return "127.0.0.1"
}
return ip
}
func dropIPv6zone(address string) string {
i := strings.IndexByte(address, '%')
if i != -1 {
address = address[:i]
idx := strings.IndexByte(address, '%')
if idx != -1 {
address = address[:idx]
}
return address
}
+78 -21
View File
@@ -10,27 +10,37 @@ import (
func TestFindClientIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: ""}
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -41,7 +51,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -50,7 +60,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -59,7 +69,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -68,7 +78,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "70.41.3.18" {
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -77,7 +87,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -87,7 +97,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.122.1" {
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -99,15 +109,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
r := &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r); ip != "@" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -119,7 +121,62 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "@", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestIsTrustedIP(t *testing.T) {
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
scenarios := []struct {
ip string
expected bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"::1", true},
{"192.168.1.1", false},
{"invalid", false},
{"@", false},
{"/tmp/miniflux.sock", false},
{"", false},
}
for _, scenario := range scenarios {
result := IsTrustedIP(scenario.ip, trustedNetworks)
if result != scenario.expected {
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
}
}
if IsTrustedIP("127.0.0.1", nil) {
t.Error("Expected false when no trusted networks are defined")
}
if IsTrustedIP("127.0.0.1", []string{}) {
t.Error("Expected false when trusted networks list is empty")
}
}
func TestFindRemoteIP(t *testing.T) {
scenarios := []struct {
ip string
expected string
}{
{"192.168.0.1:4242", "192.168.0.1"},
{"[2001:db8::1]:4242", "2001:db8::1"},
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
{"", "127.0.0.1"},
{"@", "127.0.0.1"},
{"invalid", "127.0.0.1"},
}
for _, scenario := range scenarios {
r := &http.Request{RemoteAddr: scenario.ip}
result := FindRemoteIP(r)
if result != scenario.expected {
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
}
}
}
+18 -16
View File
@@ -36,6 +36,7 @@ const (
WebAuthnDataContextKey
)
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid {
@@ -45,27 +46,27 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
return nil
}
// GoogleReaderToken returns the google reader token if it exists.
// GoogleReaderToken returns the Google Reader token from the request context, if present.
func GoogleReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderTokenKey)
}
// IsAdminUser checks if the logged user is administrator.
// IsAdminUser reports whether the logged-in user is an administrator.
func IsAdminUser(r *http.Request) bool {
return getContextBoolValue(r, IsAdminUserContextKey)
}
// IsAuthenticated returns a boolean if the user is authenticated.
// IsAuthenticated reports whether the user is authenticated.
func IsAuthenticated(r *http.Request) bool {
return getContextBoolValue(r, IsAuthenticatedContextKey)
}
// UserID returns the UserID of the logged user.
// UserID returns the logged-in user's ID from the request context.
func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
// UserName returns the logged-in user's username, or "unknown" when unset.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
@@ -74,7 +75,7 @@ func UserName(r *http.Request) string {
return value
}
// UserTimezone returns the timezone used by the logged user.
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
if value == "" {
@@ -83,7 +84,7 @@ func UserTimezone(r *http.Request) string {
return value
}
// UserLanguage get the locale used by the current logged user.
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
func UserLanguage(r *http.Request) string {
language := getContextStringValue(r, UserLanguageContextKey)
if language == "" {
@@ -92,7 +93,7 @@ func UserLanguage(r *http.Request) string {
return language
}
// UserTheme get the theme used by the current logged user.
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
func UserTheme(r *http.Request) string {
theme := getContextStringValue(r, UserThemeContextKey)
if theme == "" {
@@ -101,41 +102,42 @@ func UserTheme(r *http.Request) string {
return theme
}
// CSRF returns the current CSRF token.
// CSRF returns the CSRF token from the request context.
func CSRF(r *http.Request) string {
return getContextStringValue(r, CSRFContextKey)
}
// SessionID returns the current session ID.
// SessionID returns the current session ID from the request context.
func SessionID(r *http.Request) string {
return getContextStringValue(r, SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
// UserSessionToken returns the current user session token from the request context.
func UserSessionToken(r *http.Request) string {
return getContextStringValue(r, UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
// OAuth2State returns the OAuth2 state value from the request context.
func OAuth2State(r *http.Request) string {
return getContextStringValue(r, OAuth2StateContextKey)
}
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
func OAuth2CodeVerifier(r *http.Request) string {
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
}
// FlashMessage returns the message message if any.
// FlashMessage returns the flash message from the request context, if any.
func FlashMessage(r *http.Request) string {
return getContextStringValue(r, FlashMessageContextKey)
}
// FlashErrorMessage returns the message error message if any.
// FlashErrorMessage returns the flash error message from the request context, if any.
func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
// LastForceRefresh returns the last force refresh timestamp from the request context.
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
@@ -145,7 +147,7 @@ func LastForceRefresh(r *http.Request) time.Time {
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the context.
// ClientIP returns the client IP address stored in the request context.
func ClientIP(r *http.Request) string {
return getContextStringValue(r, ClientIPContextKey)
}
+130
View File
@@ -7,6 +7,9 @@ import (
"context"
"net/http"
"testing"
"time"
"miniflux.app/v2/internal/model"
)
func TestContextStringValue(t *testing.T) {
@@ -192,6 +195,28 @@ func TestUserID(t *testing.T) {
}
}
func TestUserName(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserName(r)
expected := "unknown"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
r = r.WithContext(ctx)
result = UserName(r)
expected = "jane"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserTimezone(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -346,6 +371,28 @@ func TestOAuth2State(t *testing.T) {
}
}
func TestOAuth2CodeVerifier(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2CodeVerifier(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
r = r.WithContext(ctx)
result = OAuth2CodeVerifier(r)
expected = "verifier"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -390,6 +437,67 @@ func TestFlashErrorMessage(t *testing.T) {
}
}
func TestLastForceRefresh(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := LastForceRefresh(r)
expected := time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx = r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Unix(1700000000, 0)
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestWebAuthnSessionData(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
ctx := r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
session := model.WebAuthnSession{}
ctx = r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result == nil {
t.Errorf("Unexpected context value, got nil instead of session")
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
@@ -411,3 +519,25 @@ func TestClientIP(t *testing.T) {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestGoogleReaderToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := GoogleReaderToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
r = r.WithContext(ctx)
result = GoogleReaderToken(r)
expected = "token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
import "net/http"
// CookieValue returns the cookie value.
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
func CookieValue(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
+15 -15
View File
@@ -7,11 +7,9 @@ import (
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// FormInt64Value returns a form value as integer.
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
func FormInt64Value(r *http.Request, param string) int64 {
value := r.FormValue(param)
integer, err := strconv.ParseInt(value, 10, 64)
@@ -22,10 +20,9 @@ func FormInt64Value(r *http.Request, param string) int64 {
return integer
}
// RouteInt64Param returns an URL route parameter as int64.
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
func RouteInt64Param(r *http.Request, param string) int64 {
vars := mux.Vars(r)
value, err := strconv.ParseInt(vars[param], 10, 64)
value, err := strconv.ParseInt(routeParam(r, param), 10, 64)
if err != nil {
return 0
}
@@ -37,13 +34,12 @@ func RouteInt64Param(r *http.Request, param string) int64 {
return value
}
// RouteStringParam returns a URL route parameter as string.
// RouteStringParam returns the named route parameter as a string.
func RouteStringParam(r *http.Request, param string) string {
vars := mux.Vars(r)
return vars[param]
return routeParam(r, param)
}
// QueryStringParam returns a query string parameter as string.
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
func QueryStringParam(r *http.Request, param, defaultValue string) string {
value := r.URL.Query().Get(param)
if value == "" {
@@ -52,7 +48,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
return value
}
// QueryStringParamList returns all values associated to the parameter.
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
func QueryStringParamList(r *http.Request, param string) []string {
var results []string
values := r.URL.Query()
@@ -69,7 +65,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
return results
}
// QueryIntParam returns a query string parameter as integer.
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
value := r.URL.Query().Get(param)
if value == "" {
@@ -88,7 +84,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
return int(val)
}
// QueryInt64Param returns a query string parameter as int64.
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
value := r.URL.Query().Get(param)
if value == "" {
@@ -107,7 +103,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
return val
}
// QueryBoolParam returns a query string parameter as bool.
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
value := r.URL.Query().Get(param)
if value == "" {
@@ -123,9 +119,13 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
return val
}
// HasQueryParam checks if the query string contains the given parameter.
// HasQueryParam reports whether the query string contains the named parameter.
func HasQueryParam(r *http.Request, param string) bool {
values := r.URL.Query()
_, ok := values[param]
return ok
}
func routeParam(r *http.Request, param string) string {
return r.PathValue(param)
}
+62 -11
View File
@@ -7,9 +7,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/gorilla/mux"
)
func TestFormInt64Value(t *testing.T) {
@@ -41,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"
@@ -59,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)
}
@@ -68,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)
@@ -100,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)
}
@@ -179,7 +178,7 @@ func TestQueryInt64Param(t *testing.T) {
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
}
result = QueryInt64Param(r, "invalid", int64(69))
result = QueryInt64Param(r, "negative", int64(69))
expected = int64(69)
if result != expected {
@@ -194,6 +193,58 @@ func TestQueryInt64Param(t *testing.T) {
}
}
func TestQueryBoolParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
r := &http.Request{URL: u}
result := QueryBoolParam(r, "truthy", false)
expected := true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "falsy", true)
expected = false
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "missing", true)
expected = true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryBoolParam(r, "invalid", true)
expected = true
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
func TestQueryStringParamList(t *testing.T) {
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
r := &http.Request{URL: u}
result := QueryStringParamList(r, "tag")
expected := []string{"alpha", "beta", "gamma"}
if !reflect.DeepEqual(result, expected) {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
result = QueryStringParamList(r, "missing")
expected = nil
if !reflect.DeepEqual(result, expected) {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
func TestHasQueryParam(t *testing.T) {
u, _ := url.Parse("http://example.org/?key=42")
r := &http.Request{URL: u}
+43 -7
View File
@@ -27,6 +27,11 @@ type Builder struct {
body any
}
// NewBuilder creates a new response builder.
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
}
// WithStatus uses the given status code to build the response.
func (b *Builder) WithStatus(statusCode int) *Builder {
b.statusCode = statusCode
@@ -39,8 +44,20 @@ func (b *Builder) WithHeader(key, value string) *Builder {
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
}
@@ -59,11 +76,12 @@ func (b *Builder) WithoutCompression() *Builder {
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
etag = normalizeETag(etag)
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public"
b.headers["Cache-Control"] = "public, immutable"
b.headers["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()
@@ -108,6 +126,7 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
b.headers["Vary"] = "Accept-Encoding"
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
@@ -141,7 +160,24 @@ 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/`))
}
+150 -43
View File
@@ -4,6 +4,7 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
@@ -20,7 +21,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 +49,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 +70,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 +92,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)
@@ -113,7 +114,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 +135,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 +155,118 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public"
expectedHeader := "public, 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, 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 +281,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 +305,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 +329,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 +340,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 +359,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 +370,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 +389,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 +400,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 +418,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 +429,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")
}
}
@@ -1,7 +1,7 @@
// 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"
package response // import "miniflux.app/v2/internal/http/response"
import (
"html"
@@ -9,20 +9,24 @@ import (
"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)
// HTML creates a new HTML response with a 200 status code.
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(body)
switch v := any(body).(type) {
case []byte:
builder.WithBodyAsBytes(v)
case string:
builder.WithBodyAsString(v)
}
builder.Write()
}
// ServerError sends an internal error to the client.
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
// 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)),
@@ -36,17 +40,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := response.New(w, r)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.WithBodyAsString(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) {
// 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)),
@@ -60,17 +64,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := response.New(w, r)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(html.EscapeString(err.Error()))
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
}
// Forbidden sends a forbidden error to the client.
func Forbidden(w http.ResponseWriter, r *http.Request) {
// 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",
@@ -83,16 +87,16 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := response.New(w, r)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody("Access Forbidden")
builder.WithBodyAsString("Access Forbidden")
builder.Write()
}
// NotFound sends a page not found error to the client.
func NotFound(w http.ResponseWriter, r *http.Request) {
// 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",
@@ -105,21 +109,21 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := response.New(w, r)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody("Page Not Found")
builder.WithBodyAsString("Page Not Found")
builder.Write()
}
// Redirect redirects the user to another location.
func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
// HTMLRedirect redirects the user to another location.
func HTMLRedirect(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) {
// 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",
@@ -132,11 +136,11 @@ func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, conten
),
)
builder := response.New(w, r)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithHeader("Content-Range", contentRange)
builder.WithBody("Range Not Satisfiable")
builder.WithBodyAsString("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)
}
}
+210
View File
@@ -0,0 +1,210 @@
// 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 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
}
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.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
}
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
}
// JSONAccepted sends an accepted response to the client.
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.Write()
}
// 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),
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.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),
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.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),
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
builder.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),
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
builder.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),
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
builder.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) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNoContent)
builder.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) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
builder.WithBodyAsString(body)
builder.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) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBodyAsString(body)
builder.Write()
}
// XMLAttachment forces the XML document to be downloaded by the web browser.
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBodyAsString(body)
builder.Write()
}
-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"))
}
}
-350
View File
@@ -1,350 +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/version"
"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),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
shouldAddServer = false
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
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)
}
if shouldAddServer {
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)
api.Serve(subrouter, store, pool)
ui.Serve(subrouter, store, pool)
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
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)
if remoteIP == "@" {
// This indicates a request sent via a Unix socket, always consider these trusted.
return true
}
for _, cidr := range config.Opts.MetricsAllowedNetworks() {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
slog.Error("Metrics endpoint accessed with invalid CIDR",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
slog.String("cidr", cidr),
)
return false
}
// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
// The recommendation is to use HTTP Basic authentication.
if network.Contains(net.ParseIP(remoteIP)) {
return true
}
}
return false
}
func printErrorAndExit(format string, a ...any) {
message := fmt.Sprintf(format, a...)
slog.Error(message)
fmt.Fprintf(os.Stderr, "%v\n", message)
os.Exit(1)
}
+70
View File
@@ -0,0 +1,70 @@
// 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/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
}
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())
}
+4 -2
View File
@@ -15,11 +15,13 @@ import (
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.FindClientIP(r)
remoteIP := request.FindRemoteIP(r)
isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
ctx := r.Context()
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if r.Header.Get("X-Forwarded-Proto") == "https" {
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.SetHTTPSValue(true)
}
+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
}
+274
View File
@@ -0,0 +1,274 @@
// 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(),
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)
}
})
}
}
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -25,7 +27,7 @@ type Client struct {
}
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
return &Client{servicesURL: serviceURL, baseURL: baseURL}
}
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
@@ -65,12 +67,12 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+28 -23
View File
@@ -4,11 +4,17 @@
package archiveorg
import (
"log/slog"
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 30 * time.Second
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
@@ -18,26 +24,25 @@ func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
func (c *Client) SendURL(entryURL string) error {
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return fmt.Errorf("archiveorg: unable to create request: %v", err)
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("archiveorg: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
}
return nil
}
+3 -2
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
return fmt.Errorf("betula: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("betula: unable to send request: %v", err)
+3 -1
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
@@ -77,12 +79,12 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
slog.String("entry_url", entry.URL),
)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
response.Body.Close()
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
@@ -10,6 +10,7 @@ import (
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
}
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
+65 -31
View File
@@ -271,23 +271,26 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
if userIntegrations.LinkwardenEnabled {
slog.Debug("Sending entry to linkwarden",
attrs := []any{
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
}
if userIntegrations.LinkwardenCollectionID != nil {
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
}
slog.Debug("Sending entry to linkwarden", attrs...)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
userIntegrations.LinkwardenCollectionID,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Linkwarden",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
attrs = append(attrs, slog.Any("error", err))
slog.Error("Unable to send entry to Linkwarden", attrs...)
}
}
@@ -406,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.WebhookEnabled {
@@ -444,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
if err := client.SaveUrl(entry.URL); err != nil {
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
@@ -540,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
slog.Debug("Unable to send new entries to Webhook",
slog.Warn("Unable to send new entries to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
@@ -639,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
slog.Int64("feed_id", feed.ID),
)
client := pushover.New(
client := pushover.NewClient(
userIntegrations.PushoverUser,
userIntegrations.PushoverToken,
feed.PushoverPriority,
@@ -655,30 +665,54 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
slog.Debug("Sending a new entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
if err := telegrambot.PushEntry(
feed,
entry,
userIntegrations.TelegramBotToken,
userIntegrations.TelegramBotChatID,
userIntegrations.TelegramBotTopicID,
userIntegrations.TelegramBotDisableWebPagePreview,
userIntegrations.TelegramBotDisableNotification,
userIntegrations.TelegramBotDisableButtons,
); err != nil {
slog.Error("Unable to send entry to Telegram",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
// Push each new entry to Readeck when push is enabled
if userIntegrations.ReadeckPushEnabled {
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
for _, entry := range entries {
slog.Debug("Sending a new entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bytes"
"log/slog"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
coll := int64(12345)
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenCollectionID: &coll,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if !strings.Contains(out, `"collection_id":12345`) {
t.Fatalf("expected collection_id in logs; got: %s", out)
}
}
func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if strings.Contains(out, "collection_id") {
t.Fatalf("did not expect collection_id in logs; got: %s", out)
}
}
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
@@ -48,7 +50,7 @@ type errorResponse struct {
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
URL string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`

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