Compare commits

...

72 Commits

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Before:

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

After:

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

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

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

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


Updates `github.com/go-webauthn/webauthn` from 0.17.0 to 0.17.2
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.17.0...v0.17.2)

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

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

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

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


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

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

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


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

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

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


Updates `github.com/go-webauthn/webauthn` from 0.16.4 to 0.17.0
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.4...v0.17.0)

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

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

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

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

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

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

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

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


Updates `github.com/go-webauthn/webauthn` from 0.16.3 to 0.16.4
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.3...v0.16.4)

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

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

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

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

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

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

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

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

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


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 16:34:01 -07:00
dependabot[bot] b2316442cf build(deps): bump github.com/go-webauthn/webauthn from 0.16.2 to 0.16.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.16.2 to 0.16.3.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.2...v0.16.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 16:28:37 -07:00
Frédéric Guillot e0b1ec24e3 ci(build): compile binaries on pull requests without uploading 2026-04-06 16:19:40 -07:00
Frédéric Guillot bca47d7dfb ci(docker): trigger PR workflow on workflow file changes 2026-04-06 13:33:14 -07:00
Luca Andrea Rossi 19f72e8ea2 docs(docker-compose): add restart policy to postgres in basic example 2026-04-06 10:30:30 -07:00
223 changed files with 4646 additions and 3163 deletions
+20 -19
View File
@@ -3,29 +3,30 @@ updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
groups:
gomod:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
directories:
- "/packaging/docker/alpine"
- "/packaging/docker/distroless"
- "/packaging/debian"
- "/packaging/rpm"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
interval: "monthly"
groups:
docker:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "monthly"
groups:
github-actions:
patterns:
- "*"
+12 -3
View File
@@ -6,6 +6,14 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- '.github/workflows/build_binaries.yml'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- '**.go'
jobs:
build:
name: Build
@@ -13,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Golang
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
check-latest: true
@@ -24,7 +32,8 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v7
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries
path: miniflux-*
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Mirror to Codeberg
+5 -5
View File
@@ -38,22 +38,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@v6
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: "/language:${{ matrix.language }}"
+11 -10
View File
@@ -11,6 +11,7 @@ on:
branches: [ main ]
paths:
- 'packaging/debian/**' # Only run on changes to the debian packaging files
- '.github/workflows/debian_packages.yml'
jobs:
test-packages:
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
@@ -18,13 +19,13 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
@@ -39,13 +40,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
@@ -54,7 +55,7 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.deb"
@@ -65,13 +66,13 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
+16 -17
View File
@@ -9,6 +9,7 @@ on:
branches: [ main ]
paths:
- 'packaging/docker/**'
- '.github/workflows/docker.yml'
jobs:
docker-images:
name: Docker Images
@@ -18,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -37,7 +38,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -51,50 +52,48 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@v9
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,11 +38,11 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
- name: Validate PR commits
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build RPM Package
@@ -31,13 +31,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.rpm"
@@ -48,7 +48,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build RPM Package
+27
View File
@@ -0,0 +1,27 @@
name: Close Stale Pull Requests
permissions: read-all
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-pr-stale: 60
days-before-pr-close: 14
stale-pr-label: stale
stale-pr-message: >
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs within 14 days.
close-pr-message: >
This pull request has been automatically closed due to inactivity.
Please feel free to reopen it if you would like to continue working on it.
days-before-issue-stale: -1
days-before-issue-close: -1
+4 -4
View File
@@ -17,9 +17,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
@@ -44,9 +44,9 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Install Postgres client
+2 -2
View File
@@ -35,7 +35,7 @@ When reporting bugs:
### Requirements
- **Git**
- **Go >= 1.24**
- **Go >= 1.26**
- **PostgreSQL**
### Getting Started
@@ -103,7 +103,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
+8 -2
View File
@@ -16,6 +16,7 @@ export PGPASSWORD := postgres
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-riscv64 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
@@ -61,6 +62,10 @@ linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-riscv64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
@@ -77,7 +82,7 @@ openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 linux-riscv64 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@@ -135,7 +140,7 @@ docker-image-distroless:
docker-images:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
--file packaging/docker/alpine/Dockerfile \
--tag $(DOCKER_IMAGE):$(VERSION) \
--push .
@@ -162,3 +167,4 @@ debian-packages: clean
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7
$(MAKE) debian DOCKER_PLATFORM=riscv64
+1 -1
View File
@@ -103,7 +103,7 @@ Features
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM and RISC-V architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
+2 -2
View File
@@ -1079,14 +1079,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
return &result, nil
}
// FlushHistory changes all entries with the status "read" to "removed".
// FlushHistory deletes all entries with the status "read".
func (c *Client) FlushHistory() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext changes all entries with the status "read" to "removed".
// FlushHistoryContext deletes all entries with the status "read".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
+2 -3
View File
@@ -10,9 +10,8 @@ import (
// Entry statuses.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
EntryStatusUnread = "unread"
EntryStatusRead = "read"
)
// User represents a user in the system.
+1
View File
@@ -21,6 +21,7 @@ services:
db:
image: postgres:latest
container_name: postgres
restart: always
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
+16 -15
View File
@@ -1,25 +1,28 @@
module miniflux.app/v2
// When changing version here don't forget to also upgrade CONTRIBUTING.md
// +heroku goVersion go1.26
go 1.26.0
require (
github.com/PuerkitoBio/goquery v1.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/coreos/go-oidc/v3 v3.18.0
github.com/go-webauthn/webauthn v0.17.3
github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2
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
github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
)
require (
github.com/go-webauthn/x v0.2.2 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
@@ -28,7 +31,7 @@ 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.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -38,12 +41,10 @@ require (
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.11 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.44.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.26.0
+28 -27
View File
@@ -8,21 +8,21 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.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/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/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=
@@ -62,14 +62,15 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.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/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -87,10 +88,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.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/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/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=
@@ -105,8 +106,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/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=
@@ -127,8 +128,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/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=
@@ -138,8 +139,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/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=
@@ -149,8 +150,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
-58
View File
@@ -2413,64 +2413,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
}
func TestCannotGetRemovedEntries(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if feedEntries.Total == 0 {
t.Fatalf(`Expected at least one entry, got none`)
}
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if updatedFeedEntries.Total != feedEntries.Total-1 {
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+6 -1
View File
@@ -115,7 +115,12 @@ func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
user, userErr := h.store.UserByID(request.UserID(r))
if userErr != nil {
response.JSONServerError(w, r, userErr)
return
}
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
} else {
categories, err = h.store.Categories(request.UserID(r))
}
+4 -7
View File
@@ -58,7 +58,6 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -79,7 +78,6 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -93,7 +91,6 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -173,7 +170,6 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
@@ -242,7 +238,6 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
if !h.store.HasSaveEntry(request.UserID(r)) {
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
@@ -292,7 +287,6 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
@@ -412,6 +406,10 @@ func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if errors.Is(err, storage.ErrEntryTombstoned) {
response.JSONBadRequest(w, r, err)
return
}
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -449,7 +447,6 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
if err != nil {
+9 -1
View File
@@ -6,6 +6,7 @@ package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
@@ -242,6 +243,13 @@ func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
h.store.RemoveUserAsync(user.ID)
go func() {
if err := h.store.RemoveUser(user.ID); err != nil {
slog.Error("Unable to delete user",
slog.Int64("user_id", user.ID),
slog.Any("error", err),
)
}
}()
response.NoContent(w, r)
}
+12 -17
View File
@@ -14,12 +14,13 @@ import (
)
func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
slog.Error("Unable to clean old web sessions", slog.Any("error", err))
} else {
slog.Info("Sessions cleanup completed",
slog.Int64("web_sessions_removed", nbWebSessions),
)
}
startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
@@ -47,17 +48,11 @@ func runCleanupTasks(store *storage.Storage) {
}
}
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
}
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
} else {
slog.Info("Clearing content from removed entries completed",
slog.Int64("removed_entries_content_cleared", contentAffected))
slog.Info("Orphan icons cleanup completed",
slog.Int64("orphan_icons_removed", nbIcons),
)
}
}
+4 -4
View File
@@ -1799,8 +1799,8 @@ func TestValidateDisableLocalAuthWithOAuth2ButNoUserCreation(t *testing.T) {
}); 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")
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
@@ -1829,8 +1829,8 @@ func TestValidateDisableLocalAuthWithAuthProxyButNoUserCreation(t *testing.T) {
}); 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")
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
+1 -6
View File
@@ -57,13 +57,8 @@ func (c *configOptions) Validate() error {
}
if c.DisableLocalAuth() {
switch {
case c.OAuth2Provider() == "" && c.AuthProxyHeader() == "":
if c.OAuth2Provider() == "" && c.AuthProxyHeader() == "" {
return errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is set. Please enable at least one authentication source")
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")
}
}
+81 -2
View File
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"errors"
"miniflux.app/v2/internal/crypto"
)
@@ -483,7 +484,7 @@ var migrations = [...]func(tx *sql.Tx) error{
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -1081,7 +1082,7 @@ var migrations = [...]func(tx *sql.Tx) error{
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -1431,4 +1432,82 @@ var migrations = [...]func(tx *sql.Tx) error{
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS user_sessions;
CREATE TABLE web_sessions (
id text not null,
secret_hash bytea not null,
user_id int references users(id) on delete cascade,
created_at timestamp with time zone not null default now(),
user_agent text not null default '',
ip inet,
state jsonb not null default '{}'::jsonb,
primary key (id),
check (jsonb_typeof(state) = 'object')
);
CREATE INDEX web_sessions_user_id_idx
ON web_sessions (user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX web_sessions_created_at_idx
ON web_sessions (created_at);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE entry_tombstones (
feed_id bigint not null references feeds(id) on delete cascade,
hash text not null check (hash <> ''),
deleted_at timestamp with time zone not null default now(),
primary key (feed_id, hash)
);
CREATE INDEX entry_tombstones_deleted_at_idx
ON entry_tombstones (deleted_at);
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
SELECT feed_id, hash, changed_at
FROM entries
WHERE status = 'removed' AND hash <> ''
ON CONFLICT (feed_id, hash) DO NOTHING;
DELETE FROM entries WHERE status = 'removed';
-- The "removed" status is no longer used, so drop the partial
-- predicate so the planner can use the index for every search.
DROP INDEX document_vectors_idx;
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DELETE FROM integrations WHERE user_id NOT IN (SELECT id FROM users);
ALTER TABLE integrations
ADD CONSTRAINT integrations_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
`)
return err
},
func(tx *sql.Tx) (err error) {
// backup_eligible is nullable: NULL marks pre-migration rows so the login path can backfill it from the assertion on first use.
_, err = tx.Exec(`
UPDATE webauthn_credentials SET name = '' WHERE name IS NULL;
ALTER TABLE webauthn_credentials
ALTER COLUMN name SET DEFAULT '',
ALTER COLUMN name SET NOT NULL,
ADD COLUMN backup_eligible boolean,
ADD COLUMN backup_state boolean NOT NULL DEFAULT false;
`)
return err
},
}
-3
View File
@@ -239,7 +239,6 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50)
switch {
@@ -294,7 +293,6 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
}
builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
response.JSONServerError(w, r, err)
@@ -414,7 +412,6 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
+2 -8
View File
@@ -238,7 +238,6 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs)
builder.WithoutStatus(model.EntryStatusRemoved)
entries, err := builder.GetEntries()
if err != nil {
@@ -255,7 +254,7 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
if read, exists := tags[ReadStream]; exists {
if read && entry.Status == model.EntryStatusUnread {
readEntryIDs = append(readEntryIDs, entry.ID)
} else if entry.Status == model.EntryStatusRead {
} else if !read && entry.Status == model.EntryStatusRead {
unreadEntryIDs = append(unreadEntryIDs, entry.ID)
}
}
@@ -265,7 +264,7 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
// filter the original array
entries[n] = entry
n++
} else if entry.Starred {
} else if !starred && entry.Starred {
unstarredEntryIDs = append(unstarredEntryIDs, entry.ID)
}
}
@@ -652,7 +651,6 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
@@ -1029,7 +1027,6 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
}
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
@@ -1050,7 +1047,6 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
@@ -1071,7 +1067,6 @@ func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *ht
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStatus(model.EntryStatusRead)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
@@ -1121,7 +1116,6 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
}
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithFeedID(feedID)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
+148 -144
View File
@@ -28,153 +28,157 @@ func newAuthMiddleware(s *storage.Storage) *authMiddleware {
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
var token string
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
token = r.Form.Get("T")
if token == "" {
slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
} else {
authorization := r.Header.Get("Authorization")
if authorization == "" {
slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
fields := strings.Fields(authorization)
if len(fields) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if fields[0] != "GoogleLogin" {
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
auths := strings.Split(fields[1], "=")
if len(auths) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if auths[0] != "auth" {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
token = auths[1]
}
parts := strings.Split(token, "/")
if len(parts) != 2 {
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w, r)
return
}
var integration *model.Integration
var user *model.User
var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if !crypto.ConstantTimeCmp(expectedToken, token) {
slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
m.serveValidated(w, r, next)
})
}
func (m *authMiddleware) serveValidated(w http.ResponseWriter, r *http.Request, next http.Handler) {
clientIP := request.ClientIP(r)
var token string
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
slog.Warn("[GoogleReader] Could not parse request form data",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
token = r.Form.Get("T")
if token == "" {
slog.Warn("[GoogleReader] Post-Form T field is empty",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
} else {
authorization := r.Header.Get("Authorization")
if authorization == "" {
slog.Warn("[GoogleReader] No token provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
fields := strings.Fields(authorization)
if len(fields) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if fields[0] != "GoogleLogin" {
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
auths := strings.Split(fields[1], "=")
if len(auths) != 2 {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if auths[0] != "auth" {
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
token = auths[1]
}
parts := strings.Split(token, "/")
if len(parts) != 2 {
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
sendUnauthorizedResponse(w, r)
return
}
var integration *model.Integration
var user *model.User
var err error
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
if !crypto.ConstantTimeCmp(expectedToken, token) {
slog.Warn("[GoogleReader] Token does not match",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
slog.Error("[GoogleReader] Unable to fetch user from database",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
sendUnauthorizedResponse(w, r)
return
}
if user == nil {
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
sendUnauthorizedResponse(w, r)
return
}
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
}
func getAuthToken(username, password string) string {
token := hex.EncodeToString(hmac.New(sha256.New, []byte(username+password)).Sum(nil))
token = username + "/" + token
-51
View File
@@ -1,51 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
)
// New creates a new cookie.
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
SameSite: http.SameSiteLaxMode,
}
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
SameSite: http.SameSiteLaxMode,
}
}
func basePath(path string) string {
if path == "" {
return "/"
}
return path
}
+26 -83
View File
@@ -5,8 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/model"
)
@@ -21,26 +19,16 @@ const (
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
UserSessionTokenContextKey
UserLanguageContextKey
UserThemeContextKey
SessionIDContextKey
CSRFContextKey
OAuth2StateContextKey
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
LastForceRefreshContextKey
WebSessionContextKey
ClientIPContextKey
GoogleReaderTokenKey
WebAuthnDataContextKey
)
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid {
return &value
// WebSession returns the current web session from the request context, if present.
func WebSession(r *http.Request) *model.WebSession {
if v := r.Context().Value(WebSessionContextKey); v != nil {
if value, valid := v.(*model.WebSession); valid {
return value
}
}
return nil
@@ -58,12 +46,30 @@ func IsAdminUser(r *http.Request) bool {
// IsAuthenticated reports whether the user is authenticated.
func IsAuthenticated(r *http.Request) bool {
return getContextBoolValue(r, IsAuthenticatedContextKey)
if getContextBoolValue(r, IsAuthenticatedContextKey) {
return true
}
if session := WebSession(r); session != nil {
return session.IsAuthenticated()
}
return false
}
// UserID returns the logged-in user's ID from the request context.
func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
return userID
}
if session := WebSession(r); session != nil {
if id, ok := session.UserID(); ok {
return id
}
}
return 0
}
// UserName returns the logged-in user's username, or "unknown" when unset.
@@ -84,69 +90,6 @@ func UserTimezone(r *http.Request) string {
return value
}
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
func UserLanguage(r *http.Request) string {
language := getContextStringValue(r, UserLanguageContextKey)
if language == "" {
language = "en_US"
}
return language
}
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
func UserTheme(r *http.Request) string {
theme := getContextStringValue(r, UserThemeContextKey)
if theme == "" {
theme = "system_serif"
}
return theme
}
// CSRF returns the CSRF token from the request context.
func CSRF(r *http.Request) string {
return getContextStringValue(r, CSRFContextKey)
}
// SessionID returns the current session ID from the request context.
func SessionID(r *http.Request) string {
return getContextStringValue(r, SessionIDContextKey)
}
// UserSessionToken returns the current user session token from the request context.
func UserSessionToken(r *http.Request) string {
return getContextStringValue(r, UserSessionTokenContextKey)
}
// OAuth2State returns the OAuth2 state value from the request context.
func OAuth2State(r *http.Request) string {
return getContextStringValue(r, OAuth2StateContextKey)
}
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
func OAuth2CodeVerifier(r *http.Request) string {
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
}
// FlashMessage returns the flash message from the request context, if any.
func FlashMessage(r *http.Request) string {
return getContextStringValue(r, FlashMessageContextKey)
}
// FlashErrorMessage returns the flash error message from the request context, if any.
func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// LastForceRefresh returns the last force refresh timestamp from the request context.
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return time.Time{}
}
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the request context.
func ClientIP(r *http.Request) string {
return getContextStringValue(r, ClientIPContextKey)
+34 -250
View File
@@ -7,11 +7,16 @@ import (
"context"
"net/http"
"testing"
"time"
"miniflux.app/v2/internal/model"
)
func newRequestWithWebSession(session *model.WebSession) *http.Request {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
return r.WithContext(ctx)
}
func TestContextStringValue(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
ctx := r.Context()
@@ -171,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
session := &model.WebSession{}
session.SetUser(&model.User{ID: 42})
r = newRequestWithWebSession(session)
result = IsAuthenticated(r)
if !result {
t.Errorf("Unexpected context value, got %v instead of true", result)
}
}
func TestUserID(t *testing.T) {
@@ -193,6 +207,17 @@ func TestUserID(t *testing.T) {
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
session := &model.WebSession{}
session.SetUser(&model.User{ID: 456})
r = newRequestWithWebSession(session)
result = UserID(r)
expected = int64(456)
if result != expected {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestUserName(t *testing.T) {
@@ -239,262 +264,21 @@ func TestUserTimezone(t *testing.T) {
}
}
func TestUserLanguage(t *testing.T) {
func TestWebSession(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserLanguage(r)
expected := "en_US"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
if result := WebSession(r); result != nil {
t.Fatalf("Unexpected context value, got %v instead of nil", result)
}
session := &model.WebSession{ID: "session-id"}
ctx := r.Context()
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
ctx = context.WithValue(ctx, WebSessionContextKey, session)
r = r.WithContext(ctx)
result = UserLanguage(r)
expected = "fr_FR"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserTheme(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserTheme(r)
expected := "system_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
r = r.WithContext(ctx)
result = UserTheme(r)
expected = "dark_serif"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestCSRF(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := CSRF(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
r = r.WithContext(ctx)
result = CSRF(r)
expected = "secret"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestSessionID(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := SessionID(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
r = r.WithContext(ctx)
result = SessionID(r)
expected = "id"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestUserSessionToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := UserSessionToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
r = r.WithContext(ctx)
result = UserSessionToken(r)
expected = "token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2State(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2State(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
r = r.WithContext(ctx)
result = OAuth2State(r)
expected = "state"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestOAuth2CodeVerifier(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := OAuth2CodeVerifier(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
r = r.WithContext(ctx)
result = OAuth2CodeVerifier(r)
expected = "verifier"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
r = r.WithContext(ctx)
result = FlashMessage(r)
expected = "message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestFlashErrorMessage(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := FlashErrorMessage(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
r = r.WithContext(ctx)
result = FlashErrorMessage(r)
expected = "error message"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestLastForceRefresh(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := LastForceRefresh(r)
expected := time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Time{}
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
ctx = r.Context()
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
r = r.WithContext(ctx)
result = LastForceRefresh(r)
expected = time.Unix(1700000000, 0)
if !result.Equal(expected) {
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
}
}
func TestWebAuthnSessionData(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
ctx := r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result != nil {
t.Errorf("Unexpected context value, got %v instead of nil", result)
}
session := model.WebAuthnSession{}
ctx = r.Context()
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
r = r.WithContext(ctx)
result = WebAuthnSessionData(r)
if result == nil {
t.Errorf("Unexpected context value, got nil instead of session")
result := WebSession(r)
if result == nil || result.ID != "session-id" {
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
}
}
+20 -1
View File
@@ -8,6 +8,7 @@ import (
"compress/gzip"
"io"
"log/slog"
"mime"
"net/http"
"strings"
"time"
@@ -64,7 +65,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = "attachment; filename=" + filename
b.headers["Content-Disposition"] = formatContentDisposition("attachment", filename)
return b
}
// WithInline suggests an inline filename for the current response.
func (b *Builder) WithInline(filename string) *Builder {
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
return b
}
@@ -181,3 +188,15 @@ func ifNoneMatch(headerValue, etag string) bool {
// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
}
func formatContentDisposition(dispositionType, filename string) string {
if filename == "" {
return dispositionType
}
if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
return value
}
return dispositionType
}
+85
View File
@@ -5,6 +5,7 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"bytes"
"mime"
"net/http"
"net/http/httptest"
"strings"
@@ -105,6 +106,90 @@ func TestBuildResponseWithAttachment(t *testing.T) {
}
}
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
actual := resp.Header.Get("Content-Disposition")
mediaType, params, err := mime.ParseMediaType(actual)
if err != nil {
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
}
if mediaType != "attachment" {
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
}
if params["filename"] != `a";filename="malware.exe` {
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
}
}
func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
actual := resp.Header.Get("Content-Disposition")
mediaType, params, err := mime.ParseMediaType(actual)
if err != nil {
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
}
if mediaType != "inline" {
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
}
if params["filename"] != `a";filename="malware.exe` {
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
}
}
func TestFormatContentDisposition(t *testing.T) {
tests := []struct {
name string
dispositionType string
filename string
expected string
}{
{"empty filename returns bare type", "inline", "", "inline"},
{"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
{"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
{"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
{"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := formatContentDisposition(tt.dispositionType, tt.filename)
if actual != tt.expected {
t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
}
})
}
}
func TestBuildResponseWithByteBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
+7 -1
View File
@@ -4,11 +4,13 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"fmt"
"html"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/urllib"
)
// HTML creates a new HTML response with a 200 status code.
@@ -117,8 +119,12 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
builder.Write()
}
// HTMLRedirect redirects the user to another location.
// HTMLRedirect redirects the user to a relative path or an absolute http(s) URL.
func HTMLRedirect(w http.ResponseWriter, r *http.Request, uri string) {
if !urllib.IsRelativePath(uri) && !urllib.IsAbsoluteURL(uri) {
HTMLBadRequest(w, r, fmt.Errorf("invalid redirect URL: %q", uri))
return
}
http.Redirect(w, r, uri, http.StatusFound)
}
+69
View File
@@ -183,6 +183,75 @@ func TestHTMLRedirectResponse(t *testing.T) {
}
}
func TestHTMLRedirectAcceptedTargets(t *testing.T) {
scenarios := []string{
"/feeds",
"/category/1/entries",
"https://example.org/article",
"http://example.org/article",
}
for _, target := range scenarios {
t.Run(target, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
HTMLRedirect(w, r, target)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf(`Unexpected status code for %q, got %d instead of %d`, target, resp.StatusCode, http.StatusFound)
}
if actualResult := resp.Header.Get("Location"); actualResult != target {
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, target)
}
})
}
}
func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
scenarios := []string{
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"mailto:victim@example.org",
"//evil.example.org/path",
"ftp://example.org/file",
"",
}
for _, target := range scenarios {
t.Run(target, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
HTMLRedirect(w, r, target)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(`Expected 400 for %q, got %d`, target, resp.StatusCode)
}
if location := resp.Header.Get("Location"); location != "" {
t.Fatalf(`Expected no Location header for %q, got %q`, target, location)
}
})
}
}
func TestHTMLRequestedRangeNotSatisfiable(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
+6 -5
View File
@@ -41,11 +41,12 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
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),
Addr: t.address,
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
ReadHeaderTimeout: config.Opts.HTTPServerTimeout(),
Handler: newRouter(store, pool),
}
switch t.mode {
+1 -1
View File
@@ -94,7 +94,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
return fmt.Errorf("wallabag: unable to save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
@@ -201,7 +201,7 @@ func TestCreateEntry(t *testing.T) {
}
w.WriteHeader(http.StatusUnauthorized)
},
errContains: "unable to get save entry",
errContains: "unable to save entry",
},
{
name: "failure due to no accessToken",
+7 -4
View File
@@ -119,6 +119,7 @@
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
"error.http_client_error": "خطأ في عميل HTTP: %v.",
"error.http_cloudflare_challenge": "هذا الموقع محمي بآلية تحدي Cloudflare (اختبار CAPTCHA أو التحقق عبر JavaScript). لا يستطيع Miniflux حل هذا التحدي تلقائياً.",
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
@@ -355,7 +356,9 @@
"form.integration.webhook_secret": "سر Webhooks",
"form.integration.webhook_url": "رابط Webhook الافتراضي",
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
"form.prefs.fieldset.authentication_settings": "مصادقة كلمة المرور",
"form.prefs.fieldset.google_authentication": "مصادقة Google",
"form.prefs.fieldset.oidc_authentication": "مصادقة %s",
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -437,7 +440,8 @@
"menu.title": "القائمة",
"menu.unread": "غير مقروء",
"menu.users": "المستخدمون",
"page.about.author": "المؤلف:",
"page.about.authors_label": "المؤلفون:",
"page.about.authors_value": "Frédéric Guillot والمساهمون",
"page.about.build_date": "تاريخ البناء:",
"page.about.credits": "شكر وتقدير",
"page.about.db_usage": "حجم قاعدة البيانات:",
@@ -557,7 +561,6 @@
"page.login.title": "تسجيل الدخول",
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
"page.new_api_key.title": "مفتاح API جديد",
"page.new_category.title": "فئة جديدة",
"page.new_user.title": "مستخدم جديد",
@@ -596,7 +599,7 @@
],
"page.settings.webauthn.last_seen_on": "آخر استخدام",
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
"page.settings.webauthn.passkeys": "مفاتيح المرور",
"page.settings.webauthn.passkeys": صادقة مفاتيح المرور",
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
"page.shared_entries.title": "المقالات المشاركة",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
"error.http_client_error": "HTTP-Client-Fehler: %v.",
"error.http_cloudflare_challenge": "Diese Webseite ist durch eine Cloudflare-Bot-Abfrage (CAPTCHA oder JavaScript-Verifizierung) geschützt. Miniflux kann diese Abfrage nicht automatisch lösen.",
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Webhook-Geheimnis",
"form.integration.webhook_url": "Standard-Webhook-URL",
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Passwort-Authentifizierung",
"form.prefs.fieldset.google_authentication": "Google-Authentifizierung",
"form.prefs.fieldset.oidc_authentication": "%s-Authentifizierung",
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Menü",
"menu.unread": "Ungelesen",
"menu.users": "Benutzer",
"page.about.author": "Autor:",
"page.about.authors_label": "Autoren:",
"page.about.authors_value": "Frédéric Guillot und Mitwirkende",
"page.about.build_date": "Datum der Kompilierung:",
"page.about.credits": "Urheberrechte",
"page.about.db_usage": "Datenbankgröße:",
@@ -533,7 +537,6 @@
"page.login.title": "Anmeldung",
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
"page.login.webauthn_login.help": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
"page.new_api_key.title": "Neuer API-Schlüssel",
"page.new_category.title": "Neue Kategorie",
"page.new_user.title": "Neuer Benutzer",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.passkey_name": "Name des Passkeys",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.passkeys": "Passkey-Authentifizierung",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.shared_entries.title": "Geteilte Artikel",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
"error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
"error.http_cloudflare_challenge": "Αυτός ο ιστότοπος προστατεύεται από πρόκληση bot του Cloudflare (CAPTCHA ή επαλήθευση JavaScript). Το Miniflux δεν μπορεί να επιλύσει αυτήν την πρόκληση αυτόματα.",
"error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
"error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
"error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Μυστικό Webhooks",
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
"form.prefs.fieldset.application_settings": "Ρυθμίσεις εφαρμογής",
"form.prefs.fieldset.authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας",
"form.prefs.fieldset.authentication_settings": "Έλεγχος ταυτότητας με κωδικό",
"form.prefs.fieldset.google_authentication": "Έλεγχος ταυτότητας Google",
"form.prefs.fieldset.oidc_authentication": "Έλεγχος ταυτότητας %s",
"form.prefs.fieldset.global_feed_settings": "Καθολικές ρυθμίσεις ροής",
"form.prefs.fieldset.reader_settings": "Ρυθμίσεις αναγνώστη",
"form.prefs.help.external_font_hosts": "Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Μενού",
"menu.unread": "Μη αναγνωσμένα",
"menu.users": "Χρήστες",
"page.about.author": "Συγγραφέας:",
"page.about.authors_label": "Συγγραφείς:",
"page.about.authors_value": "Frédéric Guillot και συνεισφέροντες",
"page.about.build_date": "Ημερομηνία Κατασκευής:",
"page.about.credits": "Συνεισφέροντες",
"page.about.db_usage": "Μέγεθος βάσης δεδομένων:",
@@ -533,7 +537,6 @@
"page.login.title": "Είσοδος",
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
"page.login.webauthn_login.help": "Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).",
"page.new_api_key.title": "Νέο κλειδί API",
"page.new_category.title": "Νέα Κατηγορία",
"page.new_user.title": "Νέος Χρήστης",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Τελευταία χρήση",
"page.settings.webauthn.passkey_name": "Όνομα κωδικού πρόσβασης",
"page.settings.webauthn.passkeys": "Κωδικοί πρόσβασης",
"page.settings.webauthn.passkeys": "Έλεγχος ταυτότητας με κωδικό πρόσβασης",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
+7 -4
View File
@@ -107,6 +107,7 @@
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_cloudflare_challenge": "This website is protected by a Cloudflare bot challenge (CAPTCHA or JavaScript verification). Miniflux cannot solve this challenge automatically.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.authentication_settings": "Password Authentication",
"form.prefs.fieldset.google_authentication": "Google Authentication",
"form.prefs.fieldset.oidc_authentication": "%s Authentication",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Menu",
"menu.unread": "Unread",
"menu.users": "Users",
"page.about.author": "Author:",
"page.about.authors_label": "Authors:",
"page.about.authors_value": "Frédéric Guillot and contributors",
"page.about.build_date": "Build Date:",
"page.about.credits": "Credits",
"page.about.db_usage": "Database size:",
@@ -533,7 +537,6 @@
"page.login.title": "Sign In",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "New API Key",
"page.new_category.title": "New Category",
"page.new_user.title": "New User",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.passkeys": "Passkey Authentication",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.shared_entries.title": "Shared entries",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
"error.http_client_error": "Error cliente HTTP: %v.",
"error.http_cloudflare_challenge": "Este sitio web está protegido por un desafío de bot de Cloudflare (CAPTCHA o verificación de JavaScript). Miniflux no puede resolver este desafío automáticamente.",
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Secreto de Webhooks",
"form.integration.webhook_url": "Defecto URL de Webhook",
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
"form.prefs.fieldset.authentication_settings": "Autenticación con contraseña",
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
"form.prefs.help.external_font_hosts": "Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Menú",
"menu.unread": "No leídos",
"menu.users": "Usuarios",
"page.about.author": "Autor:",
"page.about.authors_label": "Autores:",
"page.about.authors_value": "Frédéric Guillot y colaboradores",
"page.about.build_date": "Fecha de compilación:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Tamaño de la base de datos:",
@@ -533,7 +537,6 @@
"page.login.title": "Iniciar sesión",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
"page.login.webauthn_login.help": "Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).",
"page.new_api_key.title": "Nueva clave API",
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Usado por última vez",
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
"page.settings.webauthn.passkeys": "Claves de acceso",
"page.settings.webauthn.passkeys": "Autenticación con clave de acceso",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
"page.shared_entries.title": "Artículos compartidos",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
"error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
"error.http_client_error": "HTTP-asiakasvirhe: %v.",
"error.http_cloudflare_challenge": "Tämä sivusto on suojattu Cloudflaren bottihaasteella (CAPTCHA tai JavaScript-todennus). Miniflux ei voi ratkaista tätä haastetta automaattisesti.",
"error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
"error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
"error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Webhookien salaisuus",
"form.integration.webhook_url": "Oletus-webhook-URL",
"form.prefs.fieldset.application_settings": "Sovellusasetukset",
"form.prefs.fieldset.authentication_settings": "Todennusasetukset",
"form.prefs.fieldset.authentication_settings": "Salasanatodennus",
"form.prefs.fieldset.google_authentication": "Google-todennus",
"form.prefs.fieldset.oidc_authentication": "%s-todennus",
"form.prefs.fieldset.global_feed_settings": "Syötteiden yleisasetukset",
"form.prefs.fieldset.reader_settings": "Lukija-asetukset",
"form.prefs.help.external_font_hosts": "Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Valikko",
"menu.unread": "Lukemattomat",
"menu.users": "Käyttäjät",
"page.about.author": "Tekijä:",
"page.about.authors_label": "Tekijät:",
"page.about.authors_value": "Frédéric Guillot ja avustajat",
"page.about.build_date": "Valmistuspäivä:",
"page.about.credits": "Kiitokset",
"page.about.db_usage": "Tietokannan koko:",
@@ -533,7 +537,6 @@
"page.login.title": "Kirjaudu sisään",
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
"page.login.webauthn_login.help": "Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.",
"page.new_api_key.title": "Uusi API-avain",
"page.new_category.title": "Uusi kategoria",
"page.new_user.title": "Uusi käyttäjä",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Viimeksi käytetty",
"page.settings.webauthn.passkey_name": "Passkey-nimi",
"page.settings.webauthn.passkeys": "Passkeyt",
"page.settings.webauthn.passkeys": "Passkey-todennus",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.shared_entries.title": "Jaetut artikkelit",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Le site web n'est pas disponible pour le moment à cause d'une erreur de passerelle réseau. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_client_error": "Erreur du client HTTP : %v.",
"error.http_cloudflare_challenge": "Ce site web est protégé par un défi anti-bot Cloudflare (CAPTCHA ou vérification JavaScript). Miniflux ne peut pas résoudre ce défi automatiquement.",
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
"error.http_forbidden": "Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Secret du webhook",
"form.integration.webhook_url": "URL du webhook",
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.authentication_settings": "Authentification par mot de passe",
"form.prefs.fieldset.google_authentication": "Authentification Google",
"form.prefs.fieldset.oidc_authentication": "Authentification %s",
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
@@ -425,7 +428,8 @@
"menu.title": "Menu",
"menu.unread": "Non lus",
"menu.users": "Utilisateurs",
"page.about.author": "Auteur :",
"page.about.authors_label": "Auteurs :",
"page.about.authors_value": "Frédéric Guillot et les contributeurs",
"page.about.build_date": "Date de la compilation :",
"page.about.credits": "Crédits",
"page.about.db_usage": "Taille de la base de données :",
@@ -533,7 +537,6 @@
"page.login.title": "Connexion",
"page.login.webauthn_login": "Se connecter avec une clé daccès",
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé daccès",
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
"page.new_api_key.title": "Nouvelle clé d'API",
"page.new_category.title": "Nouvelle catégorie",
"page.new_user.title": "Nouvel Utilisateur",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.passkey_name": "Nom de la clé daccès",
"page.settings.webauthn.passkeys": "Cs daccès",
"page.settings.webauthn.passkeys": "Authentification par clé daccès",
"page.settings.webauthn.register": "Enregistrer une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.shared_entries.title": "Articles partagés",
+7 -4
View File
@@ -107,6 +107,7 @@
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
"error.http_client_error": "Erro HTTP no cliente: %v.",
"error.http_cloudflare_challenge": "Este sitio web está protexido por un desafío de bot de Cloudflare (CAPTCHA ou verificación de JavaScript). Miniflux non pode resolver este desafío automaticamente.",
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Clave secreta Webhooks",
"form.integration.webhook_url": "URL predeterminada Webhook",
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.author": "Autoría:",
"page.about.authors_label": "Autorías:",
"page.about.authors_value": "Frédéric Guillot e colaboradores",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
@@ -533,7 +537,6 @@
"page.login.title": "Acceder",
"page.login.webauthn_login": "Acceso con clave de paso",
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha chave de seguridade. Non se require isto se estás a usar unha «Clave de Paso» (credenciais descubribles).",
"page.new_api_key.title": "Nova clave da API",
"page.new_category.title": "Nova Categoría",
"page.new_user.title": "Nova Usuaria",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da Clave de Paso",
"page.settings.webauthn.passkeys": "Claves de paso",
"page.settings.webauthn.passkeys": "Autenticación con chave de paso",
"page.settings.webauthn.register": "Rexistrar Clave de paso",
"page.settings.webauthn.register.error": "Non se puido rexistrar Clave de paso",
"page.shared_entries.title": "Entradas compartidas",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
"error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v।",
"error.http_client_error": "HTTP क्लाइंट त्रुटि: %v।",
"error.http_cloudflare_challenge": "यह वेबसाइट Cloudflare बॉट चैलेंज (CAPTCHA या JavaScript सत्यापन) द्वारा सुरक्षित है। Miniflux इस चैलेंज को स्वचालित रूप से हल नहीं कर सकता।",
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "वेबहुक रहस्य",
"form.integration.webhook_url": "डिफ़ॉल्ट वेबहुक URL",
"form.prefs.fieldset.application_settings": "एप्लिकेशन सेटिंग्स",
"form.prefs.fieldset.authentication_settings": "प्रमाणीकरण सेटिंग्स",
"form.prefs.fieldset.authentication_settings": "पासवर्ड प्रमाणीकरण",
"form.prefs.fieldset.google_authentication": "Google प्रमाणीकरण",
"form.prefs.fieldset.oidc_authentication": "%s प्रमाणीकरण",
"form.prefs.fieldset.global_feed_settings": "वैश्विक फ़ीड सेटिंग्स",
"form.prefs.fieldset.reader_settings": "रीडर सेटिंग्स",
"form.prefs.help.external_font_hosts": "अनुमति प्राप्त बाहरी फ़ॉन्ट होस्ट की सूची (स्पेस से पृथक). उदाहरण: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "मेनू",
"menu.unread": "अपठित",
"menu.users": "उपयोगकर्ताओं",
"page.about.author": "रचयिता:",
"page.about.authors_label": "रचयिता:",
"page.about.authors_value": "Frédéric Guillot और योगदानकर्ता",
"page.about.build_date": "बनाने की तिथि:",
"page.about.credits": "आभार सूची",
"page.about.db_usage": "डेटाबेस आकार:",
@@ -533,7 +537,6 @@
"page.login.title": "साइन इन करें",
"page.login.webauthn_login": "पासकी से लॉगिन करें",
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
"page.login.webauthn_login.help": "यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।",
"page.new_api_key.title": "नई एपीआई कुंजी",
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "अंतिम उपयोग",
"page.settings.webauthn.passkey_name": "पासकी का नाम",
"page.settings.webauthn.passkeys": "पासकी",
"page.settings.webauthn.passkeys": "पासकी प्रमाणीकरण",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
+7 -4
View File
@@ -103,6 +103,7 @@
"error.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
"error.http_client_error": "Galat klien HTTP: %v.",
"error.http_cloudflare_challenge": "Situs web ini dilindungi oleh tantangan bot Cloudflare (CAPTCHA atau verifikasi JavaScript). Miniflux tidak dapat menyelesaikan tantangan ini secara otomatis.",
"error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
"error.http_empty_response_body": "Badan balasan HTTP kosong.",
"error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
@@ -340,7 +341,9 @@
"form.integration.webhook_secret": "Rahasia Webhook",
"form.integration.webhook_url": "URL Webhook baku",
"form.prefs.fieldset.application_settings": "Pengaturan Aplikasi",
"form.prefs.fieldset.authentication_settings": "Pengaturan Autentikasi",
"form.prefs.fieldset.authentication_settings": "Autentikasi Kata Sandi",
"form.prefs.fieldset.google_authentication": "Autentikasi Google",
"form.prefs.fieldset.oidc_authentication": "Autentikasi %s",
"form.prefs.fieldset.global_feed_settings": "Pengaturan Umpan Global",
"form.prefs.fieldset.reader_settings": "Pengaturan Pembaca",
"form.prefs.help.external_font_hosts": "Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -422,7 +425,8 @@
"menu.title": "Menu",
"menu.unread": "Belum Dibaca",
"menu.users": "Pengguna",
"page.about.author": "Pengembang:",
"page.about.authors_label": "Para Pengembang:",
"page.about.authors_value": "Frédéric Guillot dan kontributor",
"page.about.build_date": "Tanggal Penyusunan:",
"page.about.credits": "Pengembang",
"page.about.db_usage": "Ukuran basis data:",
@@ -527,7 +531,6 @@
"page.login.title": "Masuk",
"page.login.webauthn_login": "Masuk menggunakan passkey",
"page.login.webauthn_login.error": "Tidak dapat masuk menggunakan passkey",
"page.login.webauthn_login.help": "Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).",
"page.new_api_key.title": "Kunci API Baru",
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
@@ -556,7 +559,7 @@
],
"page.settings.webauthn.last_seen_on": "Terakhir Digunakan",
"page.settings.webauthn.passkey_name": "Nama Passkey",
"page.settings.webauthn.passkeys": "Passkey",
"page.settings.webauthn.passkeys": "Autentikasi Passkey",
"page.settings.webauthn.register": "Daftar passkey",
"page.settings.webauthn.register.error": "Tidak dapat mendaftarkan passkey",
"page.shared_entries.title": "Entri yang Dibagikan",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
"error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
"error.http_client_error": "Errore del client HTTP: %v.",
"error.http_cloudflare_challenge": "Questo sito web è protetto da una sfida bot di Cloudflare (CAPTCHA o verifica JavaScript). Miniflux non può risolvere questa sfida automaticamente.",
"error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
"error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
"error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Segreto dei webhook",
"form.integration.webhook_url": "URL webhook predefinito",
"form.prefs.fieldset.application_settings": "Impostazioni applicazione",
"form.prefs.fieldset.authentication_settings": "Impostazioni di autenticazione",
"form.prefs.fieldset.authentication_settings": "Autenticazione con password",
"form.prefs.fieldset.google_authentication": "Autenticazione Google",
"form.prefs.fieldset.oidc_authentication": "Autenticazione %s",
"form.prefs.fieldset.global_feed_settings": "Impostazioni globali dei feed",
"form.prefs.fieldset.reader_settings": "Impostazioni del lettore",
"form.prefs.help.external_font_hosts": "Elenco, separato da spazi, degli host di font esterni consentiti. Ad esempio: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -425,7 +428,8 @@
"menu.title": "Menù",
"menu.unread": "Da leggere",
"menu.users": "Utenti",
"page.about.author": "Autore:",
"page.about.authors_label": "Autori:",
"page.about.authors_value": "Frédéric Guillot e collaboratori",
"page.about.build_date": "Data della build:",
"page.about.credits": "Crediti",
"page.about.db_usage": "Dimensione del database:",
@@ -533,7 +537,6 @@
"page.login.title": "Accedi",
"page.login.webauthn_login": "Accedi con passkey",
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
"page.login.webauthn_login.help": "Inserisci il tuo nome utente se stai usando una chiave di sicurezza. Non è necessario con una Passkey (credenziali rilevabili).",
"page.new_api_key.title": "Nuova chiave API",
"page.new_category.title": "Nuova categoria",
"page.new_user.title": "Nuovo utente",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Ultimo uso",
"page.settings.webauthn.passkey_name": "Nome passkey",
"page.settings.webauthn.passkeys": "Passkey",
"page.settings.webauthn.passkeys": "Autenticazione con passkey",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.shared_entries.title": "Voci condivise",
+7 -4
View File
@@ -103,6 +103,7 @@
"error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
"error.http_body_read": "HTTP本文を読み取れません: %v。",
"error.http_client_error": "HTTPクライアントエラー: %v。",
"error.http_cloudflare_challenge": "このウェブサイトは Cloudflare のボットチャレンジ(CAPTCHA または JavaScript 検証)によって保護されています。Miniflux はこのチャレンジを自動的に解くことができません。",
"error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
"error.http_empty_response_body": "HTTP応答本文が空です。",
"error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",
@@ -340,7 +341,9 @@
"form.integration.webhook_secret": "Webhook シークレット",
"form.integration.webhook_url": "デフォルトの Webhook URL",
"form.prefs.fieldset.application_settings": "アプリケーション設定",
"form.prefs.fieldset.authentication_settings": "認証設定",
"form.prefs.fieldset.authentication_settings": "パスワード認証",
"form.prefs.fieldset.google_authentication": "Google 認証",
"form.prefs.fieldset.oidc_authentication": "%s 認証",
"form.prefs.fieldset.global_feed_settings": "グローバルフィード設定",
"form.prefs.fieldset.reader_settings": "リーダー設定",
"form.prefs.help.external_font_hosts": "許可する外部フォントホストをスペース区切りで指定します。例: \"fonts.gstatic.com fonts.googleapis.com\"",
@@ -422,7 +425,8 @@
"menu.title": "メニュー",
"menu.unread": "未読",
"menu.users": "ユーザー一覧",
"page.about.author": "作者:",
"page.about.authors_label": "作者:",
"page.about.authors_value": "Frédéric Guillot と貢献者",
"page.about.build_date": "ビルド日時:",
"page.about.credits": "著作権表示",
"page.about.db_usage": "データベースサイズ:",
@@ -527,7 +531,6 @@
"page.login.title": "ログイン",
"page.login.webauthn_login": "パスキーでログイン",
"page.login.webauthn_login.error": "パスキーでログインできない",
"page.login.webauthn_login.help": "セキュリティキーを使用する場合はユーザー名を入力してください。パスキー(検出可能な認証情報)の場合は不要です。",
"page.new_api_key.title": "新しい API キー",
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
@@ -556,7 +559,7 @@
],
"page.settings.webauthn.last_seen_on": "最終使用日",
"page.settings.webauthn.passkey_name": "パスキー名",
"page.settings.webauthn.passkeys": "パスキー",
"page.settings.webauthn.passkeys": "パスキー認証",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.shared_entries.title": "共有エントリ",
@@ -103,6 +103,7 @@
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
"error.http_cloudflare_challenge": "Chit ê bāng-chām hō͘ Cloudflare ê bot thiau-chiàn (CAPTCHA ah-sī JavaScript giām-chèng) pó-hō͘. Miniflux bô-hoat-tō͘ chū-tōng kái-koat chit ê thiau-chiàn.",
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
@@ -340,7 +341,9 @@
"form.integration.webhook_secret": "Webhooks bí-miâ",
"form.integration.webhook_url": "Koán-tē Webhook bāng-chí",
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
"form.prefs.fieldset.authentication_settings": "Bi̍t-bé giām-chèng",
"form.prefs.fieldset.google_authentication": "Google giām-chèng",
"form.prefs.fieldset.oidc_authentication": "%s giām-chèng",
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
@@ -422,7 +425,8 @@
"menu.title": "Tō-lám",
"menu.unread": "Ah-bōe tha̍k",
"menu.users": "Sú-iōng-lâng",
"page.about.author": "Chok-chiá: ",
"page.about.authors_label": "Chok-chiá: ",
"page.about.authors_value": "Frédéric Guillot kap kòng-hiàn-chiá",
"page.about.build_date": "Kiàn-tì li̍t-kî:",
"page.about.credits": "Pán-koân",
"page.about.db_usage": "Database chhài-chhiú:",
@@ -527,7 +531,6 @@
"page.login.title": "teng-lo̍k",
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
"page.new_api_key.title": "Sin ê API só-sî",
"page.new_category.title": "Sin lūi-pia̍t",
"page.new_user.title": "Sin sú-iōng-lâng",
@@ -556,7 +559,7 @@
],
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
"page.settings.webauthn.passkey_name": "Passkey miâ",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.passkeys": "Passkey giām-chèng",
"page.settings.webauthn.register": "Chù-chheh Passkey",
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
"error.http_client_error": "HTTP-client-fout: %v.",
"error.http_cloudflare_challenge": "Deze website wordt beschermd door een Cloudflare-botuitdaging (CAPTCHA of JavaScript-verificatie). Miniflux kan deze uitdaging niet automatisch oplossen.",
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Webhooks geheim",
"form.integration.webhook_url": "Standaard Webhook-URL",
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
"form.prefs.fieldset.authentication_settings": "Wachtwoordauthenticatie",
"form.prefs.fieldset.google_authentication": "Google-authenticatie",
"form.prefs.fieldset.oidc_authentication": "%s-authenticatie",
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
"form.prefs.help.external_font_hosts": "Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.",
@@ -425,7 +428,8 @@
"menu.title": "Menu",
"menu.unread": "Ongelezen",
"menu.users": "Gebruikers",
"page.about.author": "Auteur:",
"page.about.authors_label": "Auteurs:",
"page.about.authors_value": "Frédéric Guillot en bijdragers",
"page.about.build_date": "Compilatiedatum:",
"page.about.credits": "Credits",
"page.about.db_usage": "Databasegrootte:",
@@ -533,7 +537,6 @@
"page.login.title": "Inloggen",
"page.login.webauthn_login": "Inloggen met passkey",
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
"page.login.webauthn_login.help": "Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.",
"page.new_api_key.title": "Nieuwe API-sleutel",
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
"page.settings.webauthn.passkey_name": "Passkey Naam",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.passkeys": "Passkey-authenticatie",
"page.settings.webauthn.register": "Passkey registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
"page.shared_entries.title": "Gedeelde artikelen",
+7 -4
View File
@@ -109,6 +109,7 @@
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
"error.http_client_error": "Błąd klienta HTTP: %v.",
"error.http_cloudflare_challenge": "Ta strona jest chroniona przez wyzwanie botowe Cloudflare (CAPTCHA lub weryfikacja JavaScript). Miniflux nie może rozwiązać tego wyzwania automatycznie.",
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
@@ -346,7 +347,9 @@
"form.integration.webhook_secret": "Tajny klucz do webhooków",
"form.integration.webhook_url": "Domyślny adres URL webhooka",
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
"form.prefs.fieldset.authentication_settings": "Uwierzytelnianie hasłem",
"form.prefs.fieldset.google_authentication": "Uwierzytelnianie Google",
"form.prefs.fieldset.oidc_authentication": "Uwierzytelnianie %s",
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
@@ -428,7 +431,8 @@
"menu.title": "Menu",
"menu.unread": "Nieprzeczytane",
"menu.users": "Użytkownicy",
"page.about.author": "Autor:",
"page.about.authors_label": "Autorzy:",
"page.about.authors_value": "Frédéric Guillot i współtwórcy",
"page.about.build_date": "Data opracowania:",
"page.about.credits": "Prawa autorskie",
"page.about.db_usage": "Rozmiar bazy danych:",
@@ -539,7 +543,6 @@
"page.login.title": "Zaloguj się",
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
"page.new_api_key.title": "Nowy klucz API",
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
@@ -572,7 +575,7 @@
],
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
"page.settings.webauthn.passkeys": "Klucze dostępu",
"page.settings.webauthn.passkeys": "Uwierzytelnianie kluczem dostępu",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.shared_entries.title": "Udostępnione wpisy",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_body_read": "Não foi possível ler o corpo HTTP: %v.",
"error.http_client_error": "Erro do cliente HTTP: %v.",
"error.http_cloudflare_challenge": "Este site é protegido por um desafio de bot do Cloudflare (CAPTCHA ou verificação JavaScript). O Miniflux não consegue resolver este desafio automaticamente.",
"error.http_empty_response": "A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está vazio.",
"error.http_forbidden": "O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Segredo dos Webhooks",
"form.integration.webhook_url": "URL padrão do Webhook",
"form.prefs.fieldset.application_settings": "Configurações do aplicativo",
"form.prefs.fieldset.authentication_settings": "Configurações de autenticação",
"form.prefs.fieldset.authentication_settings": "Autenticação por senha",
"form.prefs.fieldset.google_authentication": "Autenticação Google",
"form.prefs.fieldset.oidc_authentication": "Autenticação %s",
"form.prefs.fieldset.global_feed_settings": "Configurações globais de fontes",
"form.prefs.fieldset.reader_settings": "Configurações do leitor",
"form.prefs.help.external_font_hosts": "Lista separada por espaço de hosts de fontes externas permitidos. Por exemplo: 'fonts.gstatic.com fonts.googleapis.com'.",
@@ -425,7 +428,8 @@
"menu.title": "Menu",
"menu.unread": "Não lido",
"menu.users": "Usuários",
"page.about.author": "Autor:",
"page.about.authors_label": "Autores:",
"page.about.authors_value": "Frédéric Guillot e contribuidores",
"page.about.build_date": "Compilado em:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Tamanho do banco de dados:",
@@ -533,7 +537,6 @@
"page.login.title": "Iniciar Sessão",
"page.login.webauthn_login": "Entrar com senha",
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Nova chave de API",
"page.new_category.title": "Nova categoria",
"page.new_user.title": "Novo usuário",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da senha",
"page.settings.webauthn.passkeys": "Senhas",
"page.settings.webauthn.passkeys": "Autenticação por chave de acesso",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.shared_entries.title": "Itens compartilhados",
+7 -4
View File
@@ -109,6 +109,7 @@
"error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_body_read": "Nu pot citi corpul HTTP: %v.",
"error.http_client_error": "Eroare client HTTP: %v.",
"error.http_cloudflare_challenge": "Acest site web este protejat de o provocare bot Cloudflare (CAPTCHA sau verificare JavaScript). Miniflux nu poate rezolva această provocare în mod automat.",
"error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
"error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
"error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",
@@ -346,7 +347,9 @@
"form.integration.webhook_secret": "Secret Webhook",
"form.integration.webhook_url": "URL Webhook",
"form.prefs.fieldset.application_settings": "Setări Aplicație",
"form.prefs.fieldset.authentication_settings": "Setări Autentificare",
"form.prefs.fieldset.authentication_settings": "Autentificare cu parolă",
"form.prefs.fieldset.google_authentication": "Autentificare Google",
"form.prefs.fieldset.oidc_authentication": "Autentificare %s",
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
"form.prefs.fieldset.reader_settings": "Setări Citire",
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -428,7 +431,8 @@
"menu.title": "Meniu",
"menu.unread": "Necitit",
"menu.users": "Utilizatori",
"page.about.author": "Autor:",
"page.about.authors_label": "Autori:",
"page.about.authors_value": "Frédéric Guillot și contribuitorii",
"page.about.build_date": "Dată Build:",
"page.about.credits": "Credit",
"page.about.db_usage": "Utilizare Bază de Date",
@@ -539,7 +543,6 @@
"page.login.title": "Conectare",
"page.login.webauthn_login": "Conectare cu cheia de acces",
"page.login.webauthn_login.error": "Eroare la conectarea cu cheia de acces",
"page.login.webauthn_login.help": "Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).",
"page.new_api_key.title": "Cheie API Nouă",
"page.new_category.title": "Categorie Nouă",
"page.new_user.title": "Utilizator Nou",
@@ -572,7 +575,7 @@
],
"page.settings.webauthn.last_seen_on": "Utilizat ultima dată",
"page.settings.webauthn.passkey_name": "Nume cheie acces",
"page.settings.webauthn.passkeys": "Chei Acces",
"page.settings.webauthn.passkeys": "Autentificare cu cheie de acces",
"page.settings.webauthn.register": "Înregistrare cheie acces",
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
"page.shared_entries.title": "Înregistrări partajate",
+7 -4
View File
@@ -109,6 +109,7 @@
"error.http_bad_gateway": "В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_body_read": "Невозможно прочитать тело HTTP-сообщения: %v.",
"error.http_client_error": "Ошибка HTTP-клиента: %v.",
"error.http_cloudflare_challenge": "Этот сайт защищён проверкой Cloudflare на ботов (CAPTCHA или проверка JavaScript). Miniflux не может пройти эту проверку автоматически.",
"error.http_empty_response": "Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?",
"error.http_empty_response_body": "Пустое тело HTTP-ответа.",
"error.http_forbidden": "Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?",
@@ -346,7 +347,9 @@
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
"form.integration.webhook_url": "Адрес вебхуков",
"form.prefs.fieldset.application_settings": "Настройки приложения",
"form.prefs.fieldset.authentication_settings": "Настройки аутентификации",
"form.prefs.fieldset.authentication_settings": "Аутентификация по паролю",
"form.prefs.fieldset.google_authentication": "Аутентификация Google",
"form.prefs.fieldset.oidc_authentication": "Аутентификация %s",
"form.prefs.fieldset.global_feed_settings": "Глобальные настройки подписок",
"form.prefs.fieldset.reader_settings": "Настройки чтения",
"form.prefs.help.external_font_hosts": "Список разрешённых внешних хостов для шрифтов, разделенных пробелами. Например: \"fonts.gstatic.com fonts.googleapis.com\".",
@@ -428,7 +431,8 @@
"menu.title": "Меню",
"menu.unread": "Непрочитанное",
"menu.users": "Пользователи",
"page.about.author": "Автор:",
"page.about.authors_label": "Авторы:",
"page.about.authors_value": "Frédéric Guillot и участники",
"page.about.build_date": "Дата сборки:",
"page.about.credits": "Авторы",
"page.about.db_usage": "Размер базы данных:",
@@ -539,7 +543,6 @@
"page.login.title": "Войти",
"page.login.webauthn_login": "Войти с паролем",
"page.login.webauthn_login.error": "Невозможно войти с паролем",
"page.login.webauthn_login.help": "Пожалуйста, введите имя пользователя, если вы используете ключ безопасности. Это не требуется при использовании Passkey (обнаруживаемые учетные данные).",
"page.new_api_key.title": "Новый API-ключ",
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
@@ -572,7 +575,7 @@
],
"page.settings.webauthn.last_seen_on": "Последнее использование",
"page.settings.webauthn.passkey_name": "Название ключа доступа",
"page.settings.webauthn.passkeys": "Ключи доступа",
"page.settings.webauthn.passkeys": "Аутентификация по ключу доступа",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.shared_entries.title": "Общедоступные статьи",
+7 -4
View File
@@ -106,6 +106,7 @@
"error.http_bad_gateway": "Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_body_read": "HTTP gövdesi okunamıyor: %v.",
"error.http_client_error": "HTTP istemci hatası: %v.",
"error.http_cloudflare_challenge": "Bu web sitesi bir Cloudflare bot doğrulaması (CAPTCHA veya JavaScript doğrulaması) ile korunmaktadır. Miniflux bu doğrulamayı otomatik olarak çözemez.",
"error.http_empty_response": "HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?",
"error.http_empty_response_body": "HTTP yanıt gövdesi boş.",
"error.http_forbidden": "Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?",
@@ -343,7 +344,9 @@
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Parola ile Kimlik Doğrulama",
"form.prefs.fieldset.google_authentication": "Google ile Kimlik Doğrulama",
"form.prefs.fieldset.oidc_authentication": "%s ile Kimlik Doğrulama",
"form.prefs.fieldset.global_feed_settings": "Genel Besleme Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
"form.prefs.help.external_font_hosts": "İzin verilecek harici font sunucularının boşlukla ayrılmış listesi. Örneğin: 'fonts.gstatic.com fonts.googleapis.com'.",
@@ -425,7 +428,8 @@
"menu.title": "Menü",
"menu.unread": "Okunmadı",
"menu.users": "Kullanıcılar",
"page.about.author": "Yazar:",
"page.about.authors_label": "Yazarlar:",
"page.about.authors_value": "Frédéric Guillot ve katkıda bulunanlar",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.db_usage": "Veritabanı boyutu:",
@@ -533,7 +537,6 @@
"page.login.title": "Oturum aç",
"page.login.webauthn_login": "Passkey ile giriş yap",
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
@@ -564,7 +567,7 @@
],
"page.settings.webauthn.last_seen_on": "Son Kullanım",
"page.settings.webauthn.passkey_name": "Passkey Adı",
"page.settings.webauthn.passkeys": "Passkeyler",
"page.settings.webauthn.passkeys": "Geçiş Anahtarı ile Kimlik Doğrulama",
"page.settings.webauthn.register": "Passkey'i kaydet",
"page.settings.webauthn.register.error": "Passkey kaydedilemiyor",
"page.shared_entries.title": "Paylaşılan makaleler",
+7 -4
View File
@@ -109,6 +109,7 @@
"error.http_bad_gateway": "Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_body_read": "Не вдалося прочитати HTTP-вміст: %v.",
"error.http_client_error": "Помилка HTTP-клієнта: %v.",
"error.http_cloudflare_challenge": "Цей сайт захищено перевіркою Cloudflare на ботів (CAPTCHA або перевірка JavaScript). Miniflux не може пройти цю перевірку автоматично.",
"error.http_empty_response": "Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?",
"error.http_empty_response_body": "Тіло відповіді HTTP порожнє.",
"error.http_forbidden": "Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?",
@@ -346,7 +347,9 @@
"form.integration.webhook_secret": "Секрет вебхуків",
"form.integration.webhook_url": "URL вебхука за замовчуванням",
"form.prefs.fieldset.application_settings": "Налаштування застосунку",
"form.prefs.fieldset.authentication_settings": "Налаштування автентифікації",
"form.prefs.fieldset.authentication_settings": "Автентифікація паролем",
"form.prefs.fieldset.google_authentication": "Автентифікація Google",
"form.prefs.fieldset.oidc_authentication": "Автентифікація %s",
"form.prefs.fieldset.global_feed_settings": "Глобальні налаштування стрічок",
"form.prefs.fieldset.reader_settings": "Налаштування читача",
"form.prefs.help.external_font_hosts": "Список дозволених зовнішніх хостів шрифтів, розділених пробілами. Наприклад: 'fonts.gstatic.com fonts.googleapis.com'.",
@@ -428,7 +431,8 @@
"menu.title": "Меню",
"menu.unread": "Непрочитане",
"menu.users": "Користувачі",
"page.about.author": "Автор:",
"page.about.authors_label": "Автори:",
"page.about.authors_value": "Frédéric Guillot та учасники",
"page.about.build_date": "Дата побудови:",
"page.about.credits": "Титри",
"page.about.db_usage": "Розмір бази даних:",
@@ -539,7 +543,6 @@
"page.login.title": "Вхід",
"page.login.webauthn_login": "Увійти за допомогою пароля",
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
"page.login.webauthn_login.help": "Якщо використовуєте ключ безпеки, введіть ім'я користувача. Для паролю-паскі це не потрібно.",
"page.new_api_key.title": "Створити ключ API",
"page.new_category.title": "Нова категорія",
"page.new_user.title": "Новий користувач",
@@ -572,7 +575,7 @@
],
"page.settings.webauthn.last_seen_on": "Востаннє використано",
"page.settings.webauthn.passkey_name": "Назва паскі",
"page.settings.webauthn.passkeys": "Паскі",
"page.settings.webauthn.passkeys": "Автентифікація паскі",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.shared_entries.title": "Спільні записи",
+7 -4
View File
@@ -103,6 +103,7 @@
"error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_body_read": "无法读取 HTTP 正文:%v。",
"error.http_client_error": "HTTP 客户端错误:%v。",
"error.http_cloudflare_challenge": "此网站受 Cloudflare 机器人验证(CAPTCHA 或 JavaScript 验证)保护。Miniflux 无法自动通过此验证。",
"error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
"error.http_empty_response_body": "HTTP 响应正文为空。",
"error.http_forbidden": "禁止访问该网站。可能该网站使用了反爬虫机制?",
@@ -340,7 +341,9 @@
"form.integration.webhook_secret": "Webhooks 密钥",
"form.integration.webhook_url": "默认 Webhook URL",
"form.prefs.fieldset.application_settings": "应用设置",
"form.prefs.fieldset.authentication_settings": "认证设置",
"form.prefs.fieldset.authentication_settings": "密码认证",
"form.prefs.fieldset.google_authentication": "Google 认证",
"form.prefs.fieldset.oidc_authentication": "%s 认证",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
@@ -422,7 +425,8 @@
"menu.title": "菜单",
"menu.unread": "未读",
"menu.users": "用户",
"page.about.author": "作者:",
"page.about.authors_label": "作者:",
"page.about.authors_value": "Frédéric Guillot 及贡献者",
"page.about.build_date": "构建日期:",
"page.about.credits": "鸣谢",
"page.about.db_usage": "数据库大小:",
@@ -527,7 +531,6 @@
"page.login.title": "登录",
"page.login.webauthn_login": "使用通行密钥登录",
"page.login.webauthn_login.error": "无法使用通行密钥登录",
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入。",
"page.new_api_key.title": "新的 API 密钥",
"page.new_category.title": "新建分类",
"page.new_user.title": "新建用户",
@@ -556,7 +559,7 @@
],
"page.settings.webauthn.last_seen_on": "最后使用",
"page.settings.webauthn.passkey_name": "通行密钥名称",
"page.settings.webauthn.passkeys": "通行密钥",
"page.settings.webauthn.passkeys": "通行密钥认证",
"page.settings.webauthn.register": "注册通行密钥",
"page.settings.webauthn.register.error": "无法注册通行密钥",
"page.shared_entries.title": "已共享的条目",
+36 -33
View File
@@ -12,7 +12,7 @@
"action.subscribe": "訂閱",
"action.update": "更新",
"alert.account_linked": "您的外部帳號已成功關聯!",
"alert.account_unlinked": "您的外部帳已解除關聯!",
"alert.account_unlinked": "您的外部帳已解除關聯!",
"alert.background_feed_refresh": "所有 Feed 正在背景中更新,您可以繼續使用 Miniflux。",
"alert.feed_error": "該 Feed 存在問題",
"alert.no_starred": "目前沒有收藏",
@@ -102,7 +102,8 @@
"error.fields_mandatory": "必須填寫全部資訊",
"error.http_bad_gateway": "此網站目前因閘道錯誤無法使用,問題不在 Miniflux,請稍後重試。",
"error.http_body_read": "無法讀取 HTTP 本體內容:%v。",
"error.http_client_error": "HTTP 戶端錯誤:%v。",
"error.http_client_error": "HTTP 戶端錯誤:%v。",
"error.http_cloudflare_challenge": "此網站受 Cloudflare 機器人驗證(CAPTCHA 或 JavaScript 驗證)保護。Miniflux 無法自動通過此驗證。",
"error.http_empty_response": "HTTP 回應內容為空,可能該網站有防護機制。",
"error.http_empty_response_body": "HTTP 回應本體為空。",
"error.http_forbidden": "拒絕存取此網站,可能該網站有防護機制。",
@@ -131,13 +132,13 @@
"error.password_min_length": "請至少輸入 6 個字元",
"error.proxy_url_not_empty": "代理伺服器網址不能為空。",
"error.settings_block_rule_fieldname_invalid": "無效的封鎖規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表式",
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表式",
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表式",
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表式",
"error.settings_block_rule_separator_required": "無效的封鎖規則:規則 #%d 的模式必須用 '=' 分隔",
"error.settings_invalid_domain_list": "網域清單無效。請以空白分隔多個網域。",
"error.settings_keep_rule_fieldname_invalid": "無效的保留規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
"error.settings_keep_rule_invalid_regex": "無效的保留規則:規則 #%d 的模式不是合法的正規表式",
"error.settings_keep_rule_regex_required": "無效的保留規則:規則 #%d 沒有提供正規表式",
"error.settings_keep_rule_invalid_regex": "無效的保留規則:規則 #%d 的模式不是合法的正規表式",
"error.settings_keep_rule_regex_required": "無效的保留規則:規則 #%d 沒有提供正規表式",
"error.settings_keep_rule_separator_required": "無效的保留規則:規則 #%d 的模式必須用 '=' 分隔",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_media_playback_rate_range": "播放速度超出範圍",
@@ -157,18 +158,18 @@
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
"error.user_already_exists": "使用者已存在",
"error.user_mandatory_fields": "必須填寫使用者名稱",
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
"error.linktaco_missing_required_fields": "LinkTaco API 權杖和 Organization Slug 是必需的",
"form.api_key.label.description": "API 金鑰標籤",
"form.category.hide_globally": "在全域未讀列表中隱藏文章",
"form.category.hide_globally": "在全域未讀清單中隱藏文章",
"form.category.label.title": "標題",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.integration": "第三方服務",
"form.feed.fieldset.network_settings": "網路設定",
"form.feed.fieldset.rules": "規則",
"form.feed.label.allow_self_signed_certificates": "允許自簽或無效的憑證",
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址列表",
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址清單",
"form.feed.label.block_filter_entry_rules": "條目封鎖規則",
"form.feed.label.blocklist_rules": "基於正表達式的封鎖過濾器",
"form.feed.label.blocklist_rules": "基於正表達式的封鎖過濾器",
"form.feed.label.category": "類別",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.crawler": "下載原文內容",
@@ -180,10 +181,10 @@
"form.feed.label.feed_url": "Feed 網址",
"form.feed.label.feed_username": "Feed 使用者名稱",
"form.feed.label.fetch_via_proxy": "使用應用程式層級設定的代理",
"form.feed.label.hide_globally": "在全域未讀列表中隱藏文章",
"form.feed.label.hide_globally": "在全域未讀清單中隱藏文章",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.keep_filter_entry_rules": "條目允許規則",
"form.feed.label.keeplist_rules": "基於正表達式的保留過濾器",
"form.feed.label.keeplist_rules": "基於正表達式的保留過濾器",
"form.feed.label.no_media_player": "無媒體播放器 (音訊/視訊)",
"form.feed.label.ntfy_activate": "推送文章到 ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy 預設優先順序",
@@ -193,7 +194,7 @@
"form.feed.label.ntfy_min_priority": "Ntfy 最低優先順序",
"form.feed.label.ntfy_priority": "Ntfy 優先順序",
"form.feed.label.ntfy_topic": "Ntfy topic (選填)",
"form.feed.label.proxy_url": "代理URL",
"form.feed.label.proxy_url": "代理 URL",
"form.feed.label.pushover_activate": "推送文章到 Pushover",
"form.feed.label.pushover_default_priority": "Pushover 預設優先順序",
"form.feed.label.pushover_high_priority": "Pushover 高優先順序",
@@ -206,16 +207,16 @@
"form.feed.label.site_url": "網站網址",
"form.feed.label.title": "標題",
"form.feed.label.urlrewrite_rules": "網址重寫規則",
"form.feed.label.user_agent": "覆預設的使用者代理",
"form.feed.label.webhook_url": "覆webhook URL",
"form.feed.label.user_agent": "覆預設的使用者代理",
"form.feed.label.webhook_url": "覆webhook URL",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "推送文章到 archive.org",
"form.integration.apprise_activate": "推送文章到 Apprise",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址列表",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址清單",
"form.integration.apprise_url": "Apprise API 網址",
"form.integration.betula_activate": "儲存文章到 Betula",
"form.integration.betula_token": "Betula令牌",
"form.integration.betula_token": "Betula 權杖",
"form.integration.betula_url": "Betula 伺服器網址",
"form.integration.cubox_activate": "儲存文章到 Cubox",
"form.integration.cubox_api_link": "Cubox API 連結",
@@ -252,7 +253,7 @@
"form.integration.linkding_endpoint": "Linkding API 端點",
"form.integration.linkding_tags": "Linkding 標籤",
"form.integration.linktaco_activate": "儲存文章到 LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token": "LinkTaco API 權杖",
"form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
"form.integration.linktaco_org_slug": "組織代稱",
"form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
@@ -260,7 +261,7 @@
"form.integration.linktaco_visibility": "可見性",
"form.integration.linktaco_visibility_public": "公開",
"form.integration.linktaco_visibility_private": "私人",
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳",
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳",
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
@@ -290,7 +291,7 @@
"form.integration.pinboard_activate": "儲存文章到 Pinboard",
"form.integration.pinboard_bookmark": "標記為未讀",
"form.integration.pinboard_tags": "Pinboard 標籤",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_token": "Pinboard API 權杖",
"form.integration.pushover_activate": "推送文章到 Pushover",
"form.integration.pushover_device": "Pushover 裝置(選填)",
"form.integration.pushover_prefix": "Pushover URL 前綴(選填)",
@@ -325,12 +326,12 @@
"form.integration.telegram_bot_disable_buttons": "不顯示按鈕",
"form.integration.telegram_bot_disable_notification": "停用通知",
"form.integration.telegram_bot_disable_web_page_preview": "停用網頁預覽",
"form.integration.telegram_bot_token": "Bot Token",
"form.integration.telegram_bot_token": "機器人權杖",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
"form.integration.wallabag_client_id": "Wallabag 戶端 ID",
"form.integration.wallabag_client_secret": "Wallabag 戶端金鑰",
"form.integration.wallabag_client_id": "Wallabag 戶端 ID",
"form.integration.wallabag_client_secret": "Wallabag 戶端金鑰",
"form.integration.wallabag_endpoint": "Wallabag 基本網址",
"form.integration.wallabag_only_url": "僅傳送網址(而不是完整內容)",
"form.integration.wallabag_password": "Wallabag 密碼",
@@ -338,9 +339,11 @@
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "啟用 Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook 網址",
"form.integration.webhook_url": "預設 Webhook 網址",
"form.prefs.fieldset.application_settings": "應用程式設定",
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.authentication_settings": "密碼認證",
"form.prefs.fieldset.google_authentication": "Google 認證",
"form.prefs.fieldset.oidc_authentication": "%s 認證",
"form.prefs.fieldset.global_feed_settings": "全域 Feed 設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.help.external_font_hosts": "以空白分隔允許的外部字型來源。例如:「fonts.gstatic.com fonts.googleapis.com」。",
@@ -358,7 +361,7 @@
"form.prefs.label.entry_swipe": "在觸控式螢幕上啟用文章滑動",
"form.prefs.label.external_font_hosts": "外部字型來源",
"form.prefs.label.gesture_nav": "在文章之間導覽的手勢",
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快鍵",
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快鍵",
"form.prefs.label.language": "語言",
"form.prefs.label.mark_read_manually": "僅手動標記為已讀",
"form.prefs.label.mark_read_on_media_completion": "僅在音訊/視訊播放達 90% 時標記為已讀",
@@ -422,7 +425,8 @@
"menu.title": "導覽",
"menu.unread": "未讀",
"menu.users": "使用者",
"page.about.author": "作者:",
"page.about.authors_label": "作者:",
"page.about.authors_value": "Frédéric Guillot 及貢獻者",
"page.about.build_date": "建構日期:",
"page.about.credits": "版權",
"page.about.db_usage": "資料庫大小:",
@@ -460,7 +464,7 @@
"page.edit_category.title": "編輯分類 : %s",
"page.edit_feed.etag_header": "ETag 標頭:",
"page.edit_feed.last_check": "最後檢查時間:",
"page.edit_feed.last_modified_header": "最後修改的 Header",
"page.edit_feed.last_modified_header": "最後修改的標頭",
"page.edit_feed.last_parsing_error": "最後一次解析錯誤",
"page.edit_feed.no_header": "無",
"page.edit_feed.title": "編輯 Feed : %s",
@@ -512,12 +516,12 @@
"page.keyboard_shortcuts.remove_feed": "刪除此 Feed",
"page.keyboard_shortcuts.save_article": "儲存文章",
"page.keyboard_shortcuts.scroll_item_to_top": "捲動到頂端",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示快捷鍵幫助",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示鍵盤快速鍵",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.subtitle.items": "文章導覽",
"page.keyboard_shortcuts.subtitle.pages": "頁面導覽",
"page.keyboard_shortcuts.subtitle.sections": "分欄導覽",
"page.keyboard_shortcuts.title": "快鍵",
"page.keyboard_shortcuts.title": "快鍵",
"page.keyboard_shortcuts.toggle_star_status": "切換收藏狀態",
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態,並聚焦到下一個",
@@ -527,7 +531,6 @@
"page.login.title": "登入",
"page.login.webauthn_login": "使用密碼登入",
"page.login.webauthn_login.error": "無法使用密碼登入",
"page.login.webauthn_login.help": "使用安全金鑰登入時,請輸入使用者名稱。若使用可探索式 Passkey 則無需輸入。",
"page.new_api_key.title": "新的 API 金鑰",
"page.new_category.title": "新分類",
"page.new_user.title": "新使用者",
@@ -556,7 +559,7 @@
],
"page.settings.webauthn.last_seen_on": "最後使用時間",
"page.settings.webauthn.passkey_name": "Passkey 名稱",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.passkeys": "Passkey 認證",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.shared_entries.title": "已分享的文章",
@@ -612,6 +615,6 @@
"%d 年前"
],
"time_elapsed.yesterday": "昨天",
"tooltip.keyboard_shortcuts": "快鍵:%s",
"tooltip.keyboard_shortcuts": "快鍵:%s",
"tooltip.logged_user": "目前登入 %s"
}
-69
View File
@@ -1,69 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// SessionData represents the data attached to the session.
type SessionData struct {
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
OAuth2CodeVerifier string `json:"oauth2_code_verifier"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
Theme string `json:"theme"`
LastForceRefresh string `json:"last_force_refresh"`
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
}
func (s *SessionData) String() string {
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
s.CSRF,
s.OAuth2State,
s.OAuth2CodeVerifier,
s.FlashMessage,
s.FlashErrorMessage,
s.Language,
s.Theme,
s.LastForceRefresh,
s.WebAuthnSessionData,
)
}
// Value converts the session data to JSON.
func (s *SessionData) Value() (driver.Value, error) {
j, err := json.Marshal(s)
return j, err
}
// Scan converts raw JSON data.
func (s *SessionData) Scan(src any) error {
source, ok := src.([]byte)
if !ok {
return errors.New("session: unable to assert type of src")
}
err := json.Unmarshal(source, s)
if err != nil {
return fmt.Errorf("session: %v", err)
}
return err
}
// Session represents a session in the system.
type Session struct {
ID string
Data *SessionData
}
func (s *Session) String() string {
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
}
+4 -1
View File
@@ -11,11 +11,14 @@ import (
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
DefaultSortingOrder = "published_at"
DefaultSortingDirection = "asc"
)
// MaxEntryLimit is the maximum allowed value for the "limit" query parameter
// and for the user "entries_per_page" preference.
const MaxEntryLimit = 1000
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
-30
View File
@@ -1,30 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"fmt"
"time"
"miniflux.app/v2/internal/timezone"
)
// UserSession represents a user session in the system.
type UserSession struct {
ID int64
UserID int64
Token string
CreatedAt time.Time
UserAgent string
IP string
}
func (u *UserSession) String() string {
return fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.
func (u *UserSession) UseTimezone(tz string) {
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
}
+287
View File
@@ -0,0 +1,287 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/json"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"miniflux.app/v2/internal/timezone"
)
const (
defaultSessionLanguage = "en_US"
defaultSessionTheme = "system_serif"
)
// WebSession represents a browser session persisted in the web_sessions table.
type WebSession struct {
ID string
SecretHash []byte
CreatedAt time.Time
UserAgent string
IP string
userID *int64
state webSessionState
dirty bool
}
// webSessionState stores transient browser session state as a JSON blob.
type webSessionState struct {
CSRF string `json:"csrf,omitempty"`
SuccessMessage string `json:"success_message,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
OAuth2 *WebSessionOAuth2 `json:"oauth2,omitempty"`
WebAuthn *webauthn.SessionData `json:"webauthn,omitempty"`
LastForceRefreshAt *time.Time `json:"last_force_refresh_at,omitempty"`
Language string `json:"language,omitempty"`
Theme string `json:"theme,omitempty"`
}
// WebSessionOAuth2 stores transient OAuth2 flow state.
type WebSessionOAuth2 struct {
State string `json:"state,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
}
// NewWebSession builds an unauthenticated browser session with a fresh
// identity and returns it along with the raw session secret.
func NewWebSession(userAgent, ip string) (*WebSession, string) {
secret := rand.Text()
session := &WebSession{
ID: rand.Text(),
SecretHash: hashWebSessionSecret(secret),
UserAgent: userAgent,
IP: ip,
}
session.state.CSRF = rand.Text()
return session, secret
}
// Rotate assigns a new ID and secret in place, returning the previous ID
// and the new raw secret. Rotating on authentication prevents session fixation.
func (s *WebSession) Rotate() (oldID, newSecret string) {
oldID = s.ID
newSecret = rand.Text()
s.ID = rand.Text()
s.SecretHash = hashWebSessionSecret(newSecret)
return oldID, newSecret
}
// VerifySecret reports whether the given raw secret matches the stored hash.
func (s *WebSession) VerifySecret(secret string) bool {
if secret == "" || len(s.SecretHash) == 0 {
return false
}
actual := hashWebSessionSecret(secret)
return subtle.ConstantTimeCompare(actual, s.SecretHash) == 1
}
func hashWebSessionSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// IsDirty reports whether the session has been modified since it was loaded.
func (s *WebSession) IsDirty() bool {
return s.dirty
}
// IsAuthenticated reports whether the session is bound to a user.
func (s *WebSession) IsAuthenticated() bool {
return s.userID != nil
}
// UserID returns the authenticated user ID and whether the session is bound to a user.
func (s *WebSession) UserID() (int64, bool) {
if s.userID == nil {
return 0, false
}
return *s.userID, true
}
// NullUserID returns the session user ID as a sql.NullInt64 for storage writes.
func (s *WebSession) NullUserID() sql.NullInt64 {
if s.userID == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: *s.userID, Valid: true}
}
// ScanUserID sets the session user ID from a sql.NullInt64 loaded from storage.
func (s *WebSession) ScanUserID(v sql.NullInt64) {
if !v.Valid {
s.userID = nil
return
}
id := v.Int64
s.userID = &id
}
// UseTimezone converts creation date to the given timezone.
func (s *WebSession) UseTimezone(tz string) {
s.CreatedAt = timezone.Convert(tz, s.CreatedAt)
}
// CSRF returns the CSRF token for this session.
func (s *WebSession) CSRF() string {
return s.state.CSRF
}
// Language returns the session language, or a default when unset.
func (s *WebSession) Language() string {
if s.state.Language != "" {
return s.state.Language
}
return defaultSessionLanguage
}
// Theme returns the session theme, or a default when unset.
func (s *WebSession) Theme() string {
if s.state.Theme != "" {
return s.state.Theme
}
return defaultSessionTheme
}
// OAuth2State returns the OAuth2 state parameter, or empty if not in an OAuth2 flow.
func (s *WebSession) OAuth2State() string {
if s.state.OAuth2 != nil {
return s.state.OAuth2.State
}
return ""
}
// OAuth2CodeVerifier returns the PKCE code verifier, or empty if not in an OAuth2 flow.
func (s *WebSession) OAuth2CodeVerifier() string {
if s.state.OAuth2 != nil {
return s.state.OAuth2.CodeVerifier
}
return ""
}
// ConsumeWebAuthnSession returns and clears the pending WebAuthn session data.
func (s *WebSession) ConsumeWebAuthnSession() *webauthn.SessionData {
data := s.state.WebAuthn
if data == nil {
return nil
}
s.dirty = true
s.state.WebAuthn = nil
return data
}
// LastForceRefresh returns the last force refresh timestamp, or zero time if unset.
func (s *WebSession) LastForceRefresh() time.Time {
if s.state.LastForceRefreshAt != nil {
return *s.state.LastForceRefreshAt
}
return time.Time{}
}
// ConsumeMessages returns and clears the success and error messages.
func (s *WebSession) ConsumeMessages() (string, string) {
successMessage := s.state.SuccessMessage
errorMessage := s.state.ErrorMessage
if successMessage != "" || errorMessage != "" {
s.dirty = true
s.state.SuccessMessage = ""
s.state.ErrorMessage = ""
}
return successMessage, errorMessage
}
// SetLanguage updates the language.
func (s *WebSession) SetLanguage(language string) {
s.dirty = true
s.state.Language = language
}
// SetTheme updates the theme.
func (s *WebSession) SetTheme(theme string) {
s.dirty = true
s.state.Theme = theme
}
// SetSuccessMessage stores a success message shown on the next page load.
func (s *WebSession) SetSuccessMessage(message string) {
s.dirty = true
s.state.SuccessMessage = message
}
// SetErrorMessage stores an error message shown on the next page load.
func (s *WebSession) SetErrorMessage(message string) {
s.dirty = true
s.state.ErrorMessage = message
}
// StartOAuth2Flow stores the OAuth2 state parameter and PKCE code verifier.
func (s *WebSession) StartOAuth2Flow(state, codeVerifier string) {
s.dirty = true
s.state.OAuth2 = &WebSessionOAuth2{
State: state,
CodeVerifier: codeVerifier,
}
}
// ClearOAuth2Flow discards any pending OAuth2 flow state.
func (s *WebSession) ClearOAuth2Flow() {
s.dirty = true
s.state.OAuth2 = nil
}
// SetUser binds the session to an authenticated user and copies their preferences.
func (s *WebSession) SetUser(user *User) {
if user == nil {
return
}
s.dirty = true
userID := user.ID
s.userID = &userID
s.state.Language = user.Language
s.state.Theme = user.Theme
}
// ClearUser removes the user binding from the session.
func (s *WebSession) ClearUser() {
s.dirty = true
s.userID = nil
}
// MarkForceRefreshed records the current time as the last force refresh.
func (s *WebSession) MarkForceRefreshed() {
s.dirty = true
now := time.Now().UTC()
s.state.LastForceRefreshAt = &now
}
// SetWebAuthn stores or clears WebAuthn session data.
func (s *WebSession) SetWebAuthn(data *webauthn.SessionData) {
s.dirty = true
s.state.WebAuthn = data
}
// MarshalState serializes the session state to JSON for storage.
func (s *WebSession) MarshalState() ([]byte, error) {
return json.Marshal(s.state)
}
// UnmarshalState populates the session state from raw JSON bytes.
func (s *WebSession) UnmarshalState(data []byte) error {
s.state = webSessionState{}
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, &s.state)
}
+429
View File
@@ -0,0 +1,429 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model
import (
"bytes"
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestNewWebSession(t *testing.T) {
const userAgent = "test-agent"
const ip = "127.0.0.1"
session, secret := NewWebSession(userAgent, ip)
if session == nil {
t.Fatal("NewWebSession returned a nil session")
}
if secret == "" {
t.Error("NewWebSession returned an empty secret")
}
if session.ID == "" {
t.Error("NewWebSession produced an empty ID")
}
if session.ID == secret {
t.Error("session ID and secret must not be equal")
}
if len(session.SecretHash) == 0 {
t.Error("NewWebSession produced an empty SecretHash")
}
if session.CSRF() == "" {
t.Error("NewWebSession produced an empty CSRF token")
}
if session.UserAgent != userAgent {
t.Errorf("UserAgent = %q, want %q", session.UserAgent, userAgent)
}
if session.IP != ip {
t.Errorf("IP = %q, want %q", session.IP, ip)
}
if session.IsAuthenticated() {
t.Error("a fresh session must not be authenticated")
}
if session.IsDirty() {
t.Error("a fresh session must not be dirty")
}
if !session.VerifySecret(secret) {
t.Error("VerifySecret rejected the secret returned by NewWebSession")
}
}
func TestNewWebSession_ProducesUniqueIdentities(t *testing.T) {
s1, secret1 := NewWebSession("", "")
s2, secret2 := NewWebSession("", "")
if s1.ID == s2.ID {
t.Error("successive NewWebSession calls produced the same ID")
}
if secret1 == secret2 {
t.Error("successive NewWebSession calls produced the same secret")
}
if bytes.Equal(s1.SecretHash, s2.SecretHash) {
t.Error("successive NewWebSession calls produced the same SecretHash")
}
if s1.CSRF() == s2.CSRF() {
t.Error("successive NewWebSession calls produced the same CSRF token")
}
}
func TestWebSession_Rotate(t *testing.T) {
session, originalSecret := NewWebSession("agent", "ip")
originalID := session.ID
originalHash := bytes.Clone(session.SecretHash)
originalCSRF := session.CSRF()
// Bind a user so we can verify Rotate preserves the user binding.
session.SetUser(&User{ID: 42})
oldID, newSecret := session.Rotate()
if oldID != originalID {
t.Errorf("Rotate returned oldID = %q, want %q", oldID, originalID)
}
if newSecret == "" {
t.Error("Rotate returned an empty new secret")
}
if newSecret == originalSecret {
t.Error("Rotate returned the same secret as before")
}
if session.ID == originalID {
t.Error("Rotate did not change the session ID")
}
if bytes.Equal(session.SecretHash, originalHash) {
t.Error("Rotate did not change the SecretHash")
}
if session.VerifySecret(originalSecret) {
t.Error("VerifySecret must reject the pre-rotation secret")
}
if !session.VerifySecret(newSecret) {
t.Error("VerifySecret must accept the post-rotation secret")
}
if session.CSRF() != originalCSRF {
t.Error("Rotate must preserve the CSRF token so in-flight forms remain valid")
}
if !session.IsAuthenticated() {
t.Error("Rotate must preserve the user binding")
}
if id, _ := session.UserID(); id != 42 {
t.Errorf("Rotate corrupted user ID: got %d, want 42", id)
}
}
func TestWebSession_VerifySecret(t *testing.T) {
good, goodSecret := NewWebSession("", "")
testCases := []struct {
name string
hash []byte
secret string
want bool
}{
{"correct secret", good.SecretHash, goodSecret, true},
{"wrong secret", good.SecretHash, "not-the-right-secret", false},
{"empty secret", good.SecretHash, "", false},
{"nil hash", nil, goodSecret, false},
{"empty hash and secret", nil, "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := &WebSession{SecretHash: tc.hash}
if got := s.VerifySecret(tc.secret); got != tc.want {
t.Errorf("VerifySecret(%q) = %v, want %v", tc.secret, got, tc.want)
}
})
}
}
func TestWebSession_UserBindingLifecycle(t *testing.T) {
session, _ := NewWebSession("", "")
if session.IsAuthenticated() {
t.Error("a fresh session must not be authenticated")
}
if id, ok := session.UserID(); ok || id != 0 {
t.Errorf("UserID() = (%d, %v), want (0, false)", id, ok)
}
user := &User{ID: 99, Language: "fr_FR", Theme: "dark_serif"}
session.SetUser(user)
if !session.IsAuthenticated() {
t.Error("session must be authenticated after SetUser")
}
if id, ok := session.UserID(); !ok || id != 99 {
t.Errorf("UserID() = (%d, %v), want (99, true)", id, ok)
}
if session.Language() != "fr_FR" {
t.Errorf("SetUser did not copy Language: got %q, want %q", session.Language(), "fr_FR")
}
if session.Theme() != "dark_serif" {
t.Errorf("SetUser did not copy Theme: got %q, want %q", session.Theme(), "dark_serif")
}
if !session.IsDirty() {
t.Error("SetUser must mark the session dirty")
}
session.ClearUser()
if session.IsAuthenticated() {
t.Error("session must not be authenticated after ClearUser")
}
if id, ok := session.UserID(); ok || id != 0 {
t.Errorf("UserID() after ClearUser = (%d, %v), want (0, false)", id, ok)
}
}
func TestWebSession_SetUser_NilIsNoop(t *testing.T) {
session, _ := NewWebSession("", "")
session.SetUser(nil)
if session.IsAuthenticated() {
t.Error("SetUser(nil) must not authenticate the session")
}
if session.IsDirty() {
t.Error("SetUser(nil) must not mark the session dirty")
}
}
func TestWebSession_UserIDStorageRoundTrip(t *testing.T) {
testCases := []struct {
name string
in sql.NullInt64
}{
{"null", sql.NullInt64{}},
{"zero valid", sql.NullInt64{Int64: 0, Valid: true}},
{"positive valid", sql.NullInt64{Int64: 42, Valid: true}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
session := &WebSession{}
session.ScanUserID(tc.in)
if got := session.NullUserID(); got != tc.in {
t.Errorf("round-trip = %+v, want %+v", got, tc.in)
}
if got := session.IsAuthenticated(); got != tc.in.Valid {
t.Errorf("IsAuthenticated() = %v, want %v", got, tc.in.Valid)
}
})
}
}
func TestWebSession_ScanUserID_ClearsPreviousValue(t *testing.T) {
session := &WebSession{}
session.ScanUserID(sql.NullInt64{Int64: 1, Valid: true})
session.ScanUserID(sql.NullInt64{})
if session.IsAuthenticated() {
t.Error("ScanUserID with an invalid value must clear the user binding")
}
}
func TestWebSession_LanguageAndThemeDefaults(t *testing.T) {
session := &WebSession{}
if got := session.Language(); got != defaultSessionLanguage {
t.Errorf("default Language() = %q, want %q", got, defaultSessionLanguage)
}
if got := session.Theme(); got != defaultSessionTheme {
t.Errorf("default Theme() = %q, want %q", got, defaultSessionTheme)
}
session.SetLanguage("de_DE")
session.SetTheme("light_sans_serif")
if got := session.Language(); got != "de_DE" {
t.Errorf("Language() = %q, want %q", got, "de_DE")
}
if got := session.Theme(); got != "light_sans_serif" {
t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
}
if !session.IsDirty() {
t.Error("SetLanguage/SetTheme must mark the session dirty")
}
}
func TestWebSession_OAuth2FlowLifecycle(t *testing.T) {
session := &WebSession{}
if session.OAuth2State() != "" {
t.Error("OAuth2State() must be empty by default")
}
if session.OAuth2CodeVerifier() != "" {
t.Error("OAuth2CodeVerifier() must be empty by default")
}
session.StartOAuth2Flow("state-token", "code-verifier")
if got := session.OAuth2State(); got != "state-token" {
t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
}
if got := session.OAuth2CodeVerifier(); got != "code-verifier" {
t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
}
if !session.IsDirty() {
t.Error("StartOAuth2Flow must mark the session dirty")
}
session.ClearOAuth2Flow()
if session.OAuth2State() != "" {
t.Errorf("OAuth2State() after Clear = %q, want empty", session.OAuth2State())
}
if session.OAuth2CodeVerifier() != "" {
t.Errorf("OAuth2CodeVerifier() after Clear = %q, want empty", session.OAuth2CodeVerifier())
}
}
func TestWebSession_ConsumeMessages(t *testing.T) {
t.Run("no messages", func(t *testing.T) {
session := &WebSession{}
success, errMsg := session.ConsumeMessages()
if success != "" || errMsg != "" {
t.Errorf("ConsumeMessages() = (%q, %q), want empty", success, errMsg)
}
if session.IsDirty() {
t.Error("ConsumeMessages with no messages must not mark the session dirty")
}
})
t.Run("returns and clears", func(t *testing.T) {
session := &WebSession{}
session.SetSuccessMessage("saved")
session.SetErrorMessage("nope")
session.dirty = false // isolate the dirty contribution of ConsumeMessages
success, errMsg := session.ConsumeMessages()
if success != "saved" || errMsg != "nope" {
t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "nope")
}
if !session.IsDirty() {
t.Error("ConsumeMessages with messages must mark the session dirty")
}
success, errMsg = session.ConsumeMessages()
if success != "" || errMsg != "" {
t.Errorf("second ConsumeMessages() = (%q, %q), want empty", success, errMsg)
}
})
}
func TestWebSession_ConsumeWebAuthnSession(t *testing.T) {
t.Run("no data", func(t *testing.T) {
session := &WebSession{}
if got := session.ConsumeWebAuthnSession(); got != nil {
t.Errorf("ConsumeWebAuthnSession() = %v, want nil", got)
}
if session.IsDirty() {
t.Error("ConsumeWebAuthnSession with no data must not mark the session dirty")
}
})
t.Run("returns and clears", func(t *testing.T) {
data := &webauthn.SessionData{}
session := &WebSession{}
session.SetWebAuthn(data)
session.dirty = false // isolate the dirty contribution of ConsumeWebAuthnSession
if got := session.ConsumeWebAuthnSession(); got != data {
t.Errorf("ConsumeWebAuthnSession() = %p, want %p", got, data)
}
if !session.IsDirty() {
t.Error("ConsumeWebAuthnSession with data must mark the session dirty")
}
if got := session.ConsumeWebAuthnSession(); got != nil {
t.Errorf("second ConsumeWebAuthnSession() = %v, want nil", got)
}
})
}
func TestWebSession_MarkForceRefreshed(t *testing.T) {
session := &WebSession{}
if got := session.LastForceRefresh(); !got.IsZero() {
t.Errorf("default LastForceRefresh() = %v, want zero time", got)
}
before := time.Now().UTC()
session.MarkForceRefreshed()
after := time.Now().UTC()
got := session.LastForceRefresh()
if got.Before(before) || got.After(after) {
t.Errorf("LastForceRefresh() = %v, want between %v and %v", got, before, after)
}
if !session.IsDirty() {
t.Error("MarkForceRefreshed must mark the session dirty")
}
}
func TestWebSession_StateRoundTrip(t *testing.T) {
original := &WebSession{}
original.SetLanguage("de_DE")
original.SetTheme("light_sans_serif")
original.SetSuccessMessage("saved")
original.SetErrorMessage("oops")
original.StartOAuth2Flow("state-token", "code-verifier")
original.MarkForceRefreshed()
originalRefreshAt := original.LastForceRefresh()
data, err := original.MarshalState()
if err != nil {
t.Fatalf("MarshalState() error: %v", err)
}
if !json.Valid(data) {
t.Errorf("MarshalState() produced invalid JSON: %s", data)
}
restored := &WebSession{}
if err := restored.UnmarshalState(data); err != nil {
t.Fatalf("UnmarshalState() error: %v", err)
}
if got := restored.Language(); got != "de_DE" {
t.Errorf("Language() = %q, want %q", got, "de_DE")
}
if got := restored.Theme(); got != "light_sans_serif" {
t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
}
if got := restored.OAuth2State(); got != "state-token" {
t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
}
if got := restored.OAuth2CodeVerifier(); got != "code-verifier" {
t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
}
if got := restored.LastForceRefresh(); !got.Equal(originalRefreshAt) {
t.Errorf("LastForceRefresh() = %v, want %v", got, originalRefreshAt)
}
success, errMsg := restored.ConsumeMessages()
if success != "saved" || errMsg != "oops" {
t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "oops")
}
}
func TestWebSession_UnmarshalState_EmptyDataResetsState(t *testing.T) {
session := &WebSession{}
session.SetLanguage("fr_FR")
session.StartOAuth2Flow("s", "v")
if err := session.UnmarshalState(nil); err != nil {
t.Fatalf("UnmarshalState(nil) error: %v", err)
}
if got := session.Language(); got != defaultSessionLanguage {
t.Errorf("UnmarshalState(nil) did not reset Language: got %q", got)
}
if session.OAuth2State() != "" {
t.Error("UnmarshalState(nil) did not reset OAuth2 state")
}
}
+3 -29
View File
@@ -4,47 +4,21 @@
package model // import "miniflux.app/v2/internal/model"
import (
"database/sql/driver"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
// WebAuthnSession handles marshalling / unmarshalling session data
type WebAuthnSession struct {
*webauthn.SessionData
}
func (s WebAuthnSession) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *WebAuthnSession) Scan(value any) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(b, &s)
}
func (s WebAuthnSession) String() string {
if s.SessionData == nil {
return "{}"
}
return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.Challenge, s.UserID)
}
type WebAuthnCredential struct {
Credential webauthn.Credential
Name string
AddedOn *time.Time
LastSeenOn *time.Time
Handle []byte
// False for rows predating the backup_eligible column; the login handler backfills from the assertion on first use.
BackupEligibleKnown bool
}
func (s WebAuthnCredential) HandleEncoded() string {
+2 -2
View File
@@ -33,8 +33,8 @@ func (a *AtomPerson) PersonName() string {
type atomPersons []*AtomPerson
func (a atomPersons) personNames() []string {
var names []string
authorNamesMap := make(map[string]bool)
names := make([]string, 0, len(a))
authorNamesMap := make(map[string]bool, len(a))
for _, person := range a {
personName := person.PersonName()
+70 -18
View File
@@ -4,6 +4,7 @@
package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
@@ -13,6 +14,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"syscall"
"time"
@@ -139,18 +141,40 @@ func (r *RequestBuilder) WithoutCompression() *RequestBuilder {
}
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
dialer := &net.Dialer{
var clientProxyURL *url.URL
switch {
case r.feedProxyURL != "":
var err error
clientProxyURL, err = url.Parse(r.feedProxyURL)
if err != nil {
return nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)
}
case r.useClientProxy && r.clientProxyURL != nil:
clientProxyURL = r.clientProxyURL
case r.proxyRotator != nil && r.proxyRotator.HasProxies():
clientProxyURL = r.proxyRotator.GetNextProxy()
}
directDialer := &net.Dialer{
Timeout: 10 * time.Second, // Default is 30s.
KeepAlive: 15 * time.Second, // Default is 30s.
}
proxyDialer := &net.Dialer{
Timeout: 10 * time.Second, // Default is 30s.
KeepAlive: 15 * time.Second, // Default is 30s.
}
proxyDialAddress := normalizeProxyDialAddress(clientProxyURL)
// Perform the private-network check inside the dialer's Control callback,
// which fires after DNS resolution but before the TCP connection is made.
// This eliminates TOCTOU / DNS-rebinding vulnerabilities: the resolved IP
// that is checked is exactly the IP that will be connected to.
allowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()
if !allowPrivateNetworks {
dialer.Control = func(network, address string, c syscall.RawConn) error {
directDialer.Control = func(network, address string, c syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
@@ -169,11 +193,23 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
Proxy: http.ProxyFromEnvironment,
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
ForceAttemptHTTP2: true,
DialContext: dialer.DialContext,
MaxIdleConns: 50, // Default is 100.
IdleConnTimeout: 10 * time.Second, // Default is 90s.
}
transport.DialContext = directDialer.DialContext
if !allowPrivateNetworks && proxyDialAddress != "" {
// Explicitly configured proxies are a trusted hop. Keep the private-network
// check for direct requests and redirects, but allow the connection to the proxy itself.
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if normalizeDialAddress(addr) == proxyDialAddress {
return proxyDialer.DialContext(ctx, network, addr)
}
return directDialer.DialContext(ctx, network, addr)
}
}
if r.ignoreTLSErrors {
// Add insecure ciphers if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
ciphers := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
@@ -195,21 +231,6 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
}
var clientProxyURL *url.URL
switch {
case r.feedProxyURL != "":
var err error
clientProxyURL, err = url.Parse(r.feedProxyURL)
if err != nil {
return nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)
}
case r.useClientProxy && r.clientProxyURL != nil:
clientProxyURL = r.clientProxyURL
case r.proxyRotator != nil && r.proxyRotator.HasProxies():
clientProxyURL = r.proxyRotator.GetNextProxy()
}
var clientProxyURLRedacted string
if clientProxyURL != nil {
transport.Proxy = http.ProxyURL(clientProxyURL)
@@ -261,3 +282,34 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
return client.Do(req)
}
func normalizeDialAddress(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return ""
}
return net.JoinHostPort(strings.ToLower(host), port)
}
func normalizeProxyDialAddress(proxyURL *url.URL) string {
if proxyURL == nil {
return ""
}
port := proxyURL.Port()
if port == "" {
switch strings.ToLower(proxyURL.Scheme) {
case "", "http":
port = "80"
case "https":
port = "443"
case "socks5", "socks5h":
port = "1080"
default:
return ""
}
}
return net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port)
}
@@ -12,6 +12,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/proxyrotator"
)
func TestNewRequestBuilder(t *testing.T) {
@@ -436,6 +437,80 @@ func TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {
defer resp.Body.Close()
}
func TestRequestBuilder_AllowPrivateConfiguredProxy(t *testing.T) {
configureFetcherAllowPrivateNetworksOption(t, "0")
tests := []struct {
name string
configure func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder
}{
{
name: "feed proxy",
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
return builder.WithCustomFeedProxyURL(proxyURL)
},
},
{
name: "application proxy",
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
t.Helper()
parsedProxyURL, err := url.Parse(proxyURL)
if err != nil {
t.Fatalf("Unable to parse proxy URL: %v", err)
}
return builder.WithCustomApplicationProxyURL(parsedProxyURL).UseCustomApplicationProxyURL(true)
},
},
{
name: "proxy rotator",
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
t.Helper()
rotator, err := proxyrotator.NewProxyRotator([]string{proxyURL})
if err != nil {
t.Fatalf("Unable to create proxy rotator: %v", err)
}
return builder.WithProxyRotator(rotator)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
targetURL := "http://feed.invalid/rss.xml"
proxyRequests := make(chan string, 1)
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case proxyRequests <- r.URL.String():
default:
}
w.WriteHeader(http.StatusOK)
}))
defer proxyServer.Close()
builder := tt.configure(t, NewRequestBuilder(), proxyServer.URL)
resp, err := builder.ExecuteRequest(targetURL)
if err != nil {
t.Fatalf("Expected private proxy request to succeed: %v", err)
}
defer resp.Body.Close()
select {
case gotURL := <-proxyRequests:
if gotURL != targetURL {
t.Fatalf("Expected proxy request URL to be %q, got %q", targetURL, gotURL)
}
default:
t.Fatal("Expected request to be sent through the proxy")
}
})
}
}
func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
configureFetcherAllowPrivateNetworksOption(t, "0")
+31 -10
View File
@@ -189,6 +189,10 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
}
}
if r.isCloudflareChallenge() {
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: blocked by Cloudflare challenge (%d status code)", r.httpResponse.StatusCode), "error.http_cloudflare_challenge")
}
switch r.httpResponse.StatusCode {
case http.StatusUnauthorized:
return locale.NewLocalizedErrorWrapper(errors.New("fetcher: access unauthorized (401 status code)"), "error.http_not_authorized")
@@ -224,31 +228,48 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
return nil
}
// isCloudflareChallenge reports whether the response looks like a Cloudflare
// bot/captcha interstitial rather than a genuine error from the origin. It
// relies on response headers only (no body read) to keep the check cheap and
// to run before ReadBody is called.
func (r *ResponseHandler) isCloudflareChallenge() bool {
if r.httpResponse == nil {
return false
}
return r.httpResponse.StatusCode == http.StatusForbidden &&
strings.EqualFold(r.httpResponse.Header.Get("cf-mitigated"), "challenge") &&
strings.HasPrefix(strings.ToLower(r.ContentType()), "text/html")
}
func isNetworkError(err error) bool {
if _, ok := err.(*url.Error); ok {
if _, ok := errors.AsType[*url.Error](err); ok {
return true
}
if err == io.EOF {
if errors.Is(err, io.EOF) {
return true
}
var opErr *net.OpError
if ok := errors.As(err, &opErr); ok {
if _, ok := errors.AsType[*net.OpError](err); ok {
return true
}
return false
}
func isSSLError(err error) bool {
var certErr x509.UnknownAuthorityError
if errors.As(err, &certErr) {
if _, ok := errors.AsType[x509.UnknownAuthorityError](err); ok {
return true
}
var hostErr x509.HostnameError
if errors.As(err, &hostErr) {
if _, ok := errors.AsType[x509.HostnameError](err); ok {
return true
}
var algErr x509.InsecureAlgorithmError
return errors.As(err, &algErr)
if _, ok := errors.AsType[x509.InsecureAlgorithmError](err); ok {
return true
}
return false
}
@@ -190,6 +190,95 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
}
}
func TestIsCloudflareChallenge(t *testing.T) {
makeResp := func(status int, headers map[string]string) *http.Response {
h := http.Header{}
for k, v := range headers {
h.Set(k, v)
}
return &http.Response{StatusCode: status, Header: h}
}
cases := map[string]struct {
response *http.Response
expected bool
}{
"403 with cf-mitigated challenge and html": {
response: makeResp(http.StatusForbidden, map[string]string{
"Cf-Mitigated": "challenge",
"Content-Type": "text/html; charset=UTF-8",
}),
expected: true,
},
"cf-mitigated challenge header on 200": {
response: makeResp(http.StatusOK, map[string]string{
"Cf-Mitigated": "challenge",
"Content-Type": "text/html",
}),
expected: false,
},
"403 cf-mitigated challenge without html": {
response: makeResp(http.StatusForbidden, map[string]string{
"Cf-Mitigated": "challenge",
"Content-Type": "application/json",
}),
expected: false,
},
"403 from cloudflare with html but no challenge signal": {
response: makeResp(http.StatusForbidden, map[string]string{
"Server": "cloudflare",
"Cf-Ray": "8abc123def456-IAD",
"Content-Type": "text/html; charset=UTF-8",
}),
expected: false,
},
"503 from cloudflare with html but no challenge signal": {
response: makeResp(http.StatusServiceUnavailable, map[string]string{
"Server": "cloudflare",
"Cf-Ray": "8abc123def456-IAD",
"Content-Type": "text/html",
}),
expected: false,
},
"403 from non-cloudflare server": {
response: makeResp(http.StatusForbidden, map[string]string{
"Server": "nginx",
"Content-Type": "text/html",
}),
expected: false,
},
"500 from cloudflare with html": {
response: makeResp(http.StatusInternalServerError, map[string]string{
"Server": "cloudflare",
"Cf-Ray": "8abc123def456-IAD",
"Content-Type": "text/html",
}),
expected: false,
},
"200 OK from cloudflare": {
response: makeResp(http.StatusOK, map[string]string{
"Server": "cloudflare",
"Cf-Ray": "8abc123def456-IAD",
"Content-Type": "application/rss+xml",
}),
expected: false,
},
"nil response": {
response: nil,
expected: false,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
rh := &ResponseHandler{httpResponse: tc.response}
if got := rh.isCloudflareChallenge(); got != tc.expected {
t.Errorf("isCloudflareChallenge() = %v, want %v", got, tc.expected)
}
})
}
}
func TestResponseHandlerCloseClosesBodyOnClientError(t *testing.T) {
body := &testReadCloser{}
rh := ResponseHandler{
+117 -35
View File
@@ -9,6 +9,7 @@ import (
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
// Handler handles the logic for OPML import/export.
@@ -31,6 +32,24 @@ func (h *Handler) Export(userID int64) (string, error) {
SiteURL: feed.SiteURL,
Description: feed.Description,
CategoryName: feed.Category.Title,
ScraperRules: feed.ScraperRules,
RewriteRules: feed.RewriteRules,
UrlRewriteRules: feed.UrlRewriteRules,
BlocklistRules: feed.BlocklistRules,
KeeplistRules: feed.KeeplistRules,
BlockFilterEntryRules: feed.BlockFilterEntryRules,
KeepFilterEntryRules: feed.KeepFilterEntryRules,
UserAgent: feed.UserAgent,
Crawler: feed.Crawler,
IgnoreHTTPCache: feed.IgnoreHTTPCache,
FetchViaProxy: feed.FetchViaProxy,
Disabled: feed.Disabled,
NoMediaPlayer: feed.NoMediaPlayer,
HideGlobally: feed.HideGlobally,
AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,
DisableHTTP2: feed.DisableHTTP2,
IgnoreEntryUpdates: feed.IgnoreEntryUpdates,
})
}
@@ -45,42 +64,105 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
}
for _, subscription := range subscriptions {
if !h.store.FeedURLExists(userID, subscription.FeedURL) {
var category *model.Category
var err error
if subscription.CategoryName == "" {
category, err = h.store.FirstCategory(userID)
if err != nil {
return fmt.Errorf("opml: unable to find first category: %w", err)
}
} else {
category, err = h.store.CategoryByTitle(userID, subscription.CategoryName)
if err != nil {
return fmt.Errorf("opml: unable to search category by title: %w", err)
}
if category == nil {
category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: subscription.CategoryName})
if err != nil {
return fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName)
}
}
}
feed := &model.Feed{
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
Category: category,
}
if err := h.store.CreateFeed(feed); err != nil {
return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
}
if h.store.FeedURLExists(userID, subscription.FeedURL) {
continue
}
category, err := h.resolveCategory(userID, subscription.CategoryName)
if err != nil {
return err
}
if validationErr := validateSubscription(userID, category.ID, h.store, subscription); validationErr != nil {
return fmt.Errorf(`opml: invalid feed settings for %q: %w`, subscription.FeedURL, validationErr)
}
feed := &model.Feed{
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
Category: category,
}
applySubscriptionSettings(feed, subscription)
if err := h.store.CreateFeed(feed); err != nil {
return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
}
}
return nil
}
func (h *Handler) resolveCategory(userID int64, categoryName string) (*model.Category, error) {
if categoryName == "" {
category, err := h.store.FirstCategory(userID)
if err != nil {
return nil, fmt.Errorf("opml: unable to find first category: %w", err)
}
return category, nil
}
category, err := h.store.CategoryByTitle(userID, categoryName)
if err != nil {
return nil, fmt.Errorf("opml: unable to search category by title: %w", err)
}
if category == nil {
category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: categoryName})
if err != nil {
return nil, fmt.Errorf(`opml: unable to create this category: %q`, categoryName)
}
}
return category, nil
}
func applySubscriptionSettings(feed *model.Feed, s subcription) {
feed.ScraperRules = s.ScraperRules
feed.RewriteRules = s.RewriteRules
feed.UrlRewriteRules = s.UrlRewriteRules
feed.BlocklistRules = s.BlocklistRules
feed.KeeplistRules = s.KeeplistRules
feed.BlockFilterEntryRules = s.BlockFilterEntryRules
feed.KeepFilterEntryRules = s.KeepFilterEntryRules
feed.UserAgent = s.UserAgent
feed.Crawler = s.Crawler
feed.IgnoreHTTPCache = s.IgnoreHTTPCache
feed.FetchViaProxy = s.FetchViaProxy
feed.Disabled = s.Disabled
feed.NoMediaPlayer = s.NoMediaPlayer
feed.HideGlobally = s.HideGlobally
feed.AllowSelfSignedCertificates = s.AllowSelfSignedCertificates
feed.DisableHTTP2 = s.DisableHTTP2
feed.IgnoreEntryUpdates = s.IgnoreEntryUpdates
}
func validateSubscription(userID, categoryID int64, store *storage.Storage, s subcription) error {
feedCreationRequest := &model.FeedCreationRequest{
FeedURL: s.FeedURL,
CategoryID: categoryID,
UserAgent: s.UserAgent,
Crawler: s.Crawler,
IgnoreEntryUpdates: s.IgnoreEntryUpdates,
Disabled: s.Disabled,
NoMediaPlayer: s.NoMediaPlayer,
IgnoreHTTPCache: s.IgnoreHTTPCache,
AllowSelfSignedCertificates: s.AllowSelfSignedCertificates,
FetchViaProxy: s.FetchViaProxy,
HideGlobally: s.HideGlobally,
DisableHTTP2: s.DisableHTTP2,
ScraperRules: s.ScraperRules,
RewriteRules: s.RewriteRules,
BlocklistRules: s.BlocklistRules,
KeeplistRules: s.KeeplistRules,
BlockFilterEntryRules: s.BlockFilterEntryRules,
KeepFilterEntryRules: s.KeepFilterEntryRules,
UrlRewriteRules: s.UrlRewriteRules,
}
if validationErr := validator.ValidateFeedCreation(store, userID, feedCreationRequest); validationErr != nil {
return validationErr.Error()
}
return nil
+98 -4
View File
@@ -5,15 +5,20 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
import (
"encoding/xml"
"fmt"
"strconv"
"strings"
)
const minifluxOPMLNamespace = "https://miniflux.app/opml"
// Specs: http://opml.org/spec2.opml
type opmlDocument struct {
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Header opmlHeader `xml:"head"`
Outlines opmlOutlineCollection `xml:"body>outline"`
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
MinifluxNamespace string `xml:"xmlns:miniflux,attr,omitempty"`
Header opmlHeader `xml:"head"`
Outlines opmlOutlineCollection `xml:"body>outline"`
}
type opmlHeader struct {
@@ -29,6 +34,25 @@ type opmlOutline struct {
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Description string `xml:"description,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
// Miniflux-specific feed settings
ScraperRules string `xml:"miniflux:scraperRules,attr,omitempty"`
RewriteRules string `xml:"miniflux:rewriteRules,attr,omitempty"`
UrlRewriteRules string `xml:"miniflux:urlRewriteRules,attr,omitempty"`
BlocklistRules string `xml:"miniflux:blocklistRules,attr,omitempty"`
KeeplistRules string `xml:"miniflux:keeplistRules,attr,omitempty"`
BlockFilterEntryRules string `xml:"miniflux:blockFilterEntryRules,attr,omitempty"`
KeepFilterEntryRules string `xml:"miniflux:keepFilterEntryRules,attr,omitempty"`
UserAgent string `xml:"miniflux:userAgent,attr,omitempty"`
Crawler bool `xml:"miniflux:crawler,attr,omitempty"`
IgnoreHTTPCache bool `xml:"miniflux:ignoreHTTPCache,attr,omitempty"`
FetchViaProxy bool `xml:"miniflux:fetchViaProxy,attr,omitempty"`
Disabled bool `xml:"miniflux:disabled,attr,omitempty"`
NoMediaPlayer bool `xml:"miniflux:noMediaPlayer,attr,omitempty"`
HideGlobally bool `xml:"miniflux:hideGlobally,attr,omitempty"`
AllowSelfSignedCertificates bool `xml:"miniflux:allowSelfSignedCertificates,attr,omitempty"`
DisableHTTP2 bool `xml:"miniflux:disableHTTP2,attr,omitempty"`
IgnoreEntryUpdates bool `xml:"miniflux:ignoreEntryUpdates,attr,omitempty"`
}
func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
@@ -48,6 +72,25 @@ func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
}, start)
}
func (o *opmlOutline) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
*o = opmlOutline{}
type opmlOutlineAlias opmlOutline
if err := d.DecodeElement((*opmlOutlineAlias)(o), &start); err != nil {
return err
}
for _, attr := range start.Attr {
if attr.Name.Space == minifluxOPMLNamespace {
if err := o.setMinifluxAttribute(attr.Name.Local, attr.Value); err != nil {
return err
}
}
}
return nil
}
func (o opmlOutline) IsSubscription() bool {
return strings.TrimSpace(o.FeedURL) != ""
}
@@ -85,3 +128,54 @@ type opmlOutlineCollection []opmlOutline
func (o opmlOutlineCollection) HasChildren() bool {
return len(o) > 0
}
func (o *opmlOutline) setMinifluxAttribute(name, value string) error {
switch name {
case "scraperRules":
o.ScraperRules = value
case "rewriteRules":
o.RewriteRules = value
case "urlRewriteRules":
o.UrlRewriteRules = value
case "blocklistRules":
o.BlocklistRules = value
case "keeplistRules":
o.KeeplistRules = value
case "blockFilterEntryRules":
o.BlockFilterEntryRules = value
case "keepFilterEntryRules":
o.KeepFilterEntryRules = value
case "userAgent":
o.UserAgent = value
case "crawler":
return setMinifluxBoolAttribute(name, value, &o.Crawler)
case "ignoreHTTPCache":
return setMinifluxBoolAttribute(name, value, &o.IgnoreHTTPCache)
case "fetchViaProxy":
return setMinifluxBoolAttribute(name, value, &o.FetchViaProxy)
case "disabled":
return setMinifluxBoolAttribute(name, value, &o.Disabled)
case "noMediaPlayer":
return setMinifluxBoolAttribute(name, value, &o.NoMediaPlayer)
case "hideGlobally":
return setMinifluxBoolAttribute(name, value, &o.HideGlobally)
case "allowSelfSignedCertificates":
return setMinifluxBoolAttribute(name, value, &o.AllowSelfSignedCertificates)
case "disableHTTP2":
return setMinifluxBoolAttribute(name, value, &o.DisableHTTP2)
case "ignoreEntryUpdates":
return setMinifluxBoolAttribute(name, value, &o.IgnoreEntryUpdates)
}
return nil
}
func setMinifluxBoolAttribute(name, value string, target *bool) error {
parsedValue, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("opml: invalid miniflux attribute %q: %w", name, err)
}
*target = parsedValue
return nil
}
+18
View File
@@ -38,6 +38,24 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
SiteURL: outline.GetSiteURL(),
Description: outline.Description,
CategoryName: category,
ScraperRules: outline.ScraperRules,
RewriteRules: outline.RewriteRules,
UrlRewriteRules: outline.UrlRewriteRules,
BlocklistRules: outline.BlocklistRules,
KeeplistRules: outline.KeeplistRules,
BlockFilterEntryRules: outline.BlockFilterEntryRules,
KeepFilterEntryRules: outline.KeepFilterEntryRules,
UserAgent: outline.UserAgent,
Crawler: outline.Crawler,
IgnoreHTTPCache: outline.IgnoreHTTPCache,
FetchViaProxy: outline.FetchViaProxy,
Disabled: outline.Disabled,
NoMediaPlayer: outline.NoMediaPlayer,
HideGlobally: outline.HideGlobally,
AllowSelfSignedCertificates: outline.AllowSelfSignedCertificates,
DisableHTTP2: outline.DisableHTTP2,
IgnoreEntryUpdates: outline.IgnoreEntryUpdates,
})
} else if outline.Outlines.HasChildren() {
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)
+80 -14
View File
@@ -8,13 +8,6 @@ import (
"testing"
)
// equals compare two subscriptions.
func (s subcription) equals(subscription subcription) bool {
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
s.Description == subscription.Description
}
func TestParseOpmlWithoutCategories(t *testing.T) {
data := `<?xml version="1.0" encoding="ISO-8859-1"?>
<opml version="2.0">
@@ -51,7 +44,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
}
if !subscriptions[0].equals(expected[0]) {
if subscriptions[0] != expected[0] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[0], expected[0])
}
}
@@ -89,7 +82,7 @@ func TestParseOpmlWithCategories(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -122,7 +115,7 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -160,7 +153,7 @@ func TestParseOpmlVersion1(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -194,7 +187,7 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -236,7 +229,7 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -269,7 +262,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
}
for i := range len(subscriptions) {
if !subscriptions[i].equals(expected[i]) {
if subscriptions[i] != expected[i] {
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
}
}
@@ -282,3 +275,76 @@ func TestParseInvalidXML(t *testing.T) {
t.Error("Parse should generate an error")
}
}
func TestParseOpmlWithMinifluxSettings(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<opml version="2.0" xmlns:miniflux="https://miniflux.app/opml">
<head><title>Miniflux</title></head>
<body>
<outline text="My Category">
<outline type="rss"
text="Feed 1"
title="Feed 1"
xmlUrl="http://example.org/feed1/"
htmlUrl="http://example.org/1"
miniflux:scraperRules="article.content"
miniflux:rewriteRules="replace(&quot;foo&quot;|&quot;bar&quot;)"
miniflux:urlRewriteRules="rewrite(&quot;^https://old&quot;|&quot;https://new&quot;)"
miniflux:blocklistRules="sponsored"
miniflux:keeplistRules="important"
miniflux:blockFilterEntryRules="EntryTitle=~&quot;ad&quot;"
miniflux:keepFilterEntryRules="EntryTitle=~&quot;news&quot;"
miniflux:userAgent="CustomAgent/1.0"
miniflux:proxyUrl="http://proxy.example.org"
miniflux:crawler="true"
miniflux:ignoreHTTPCache="true"
miniflux:fetchViaProxy="true"
miniflux:disabled="true"
miniflux:noMediaPlayer="true"
miniflux:hideGlobally="true"
miniflux:allowSelfSignedCertificates="true"
miniflux:disableHTTP2="true"
miniflux:ignoreEntryUpdates="true"
/>
</outline>
</body>
</opml>
`
expected := subcription{
Title: "Feed 1",
FeedURL: "http://example.org/feed1/",
SiteURL: "http://example.org/1",
CategoryName: "My Category",
ScraperRules: "article.content",
RewriteRules: `replace("foo"|"bar")`,
UrlRewriteRules: `rewrite("^https://old"|"https://new")`,
BlocklistRules: "sponsored",
KeeplistRules: "important",
BlockFilterEntryRules: `EntryTitle=~"ad"`,
KeepFilterEntryRules: `EntryTitle=~"news"`,
UserAgent: "CustomAgent/1.0",
Crawler: true,
IgnoreHTTPCache: true,
FetchViaProxy: true,
Disabled: true,
NoMediaPlayer: true,
HideGlobally: true,
AllowSelfSignedCertificates: true,
DisableHTTP2: true,
IgnoreEntryUpdates: true,
}
subscriptions, err := parse(bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(subscriptions) != 1 {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
}
if subscriptions[0] != expected {
t.Errorf("Subscription is different:\ngot: %+v\nwant: %+v", subscriptions[0], expected)
}
}
+19
View File
@@ -34,6 +34,7 @@ func serialize(subscriptions []subcription) string {
func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
opmlDocument := &opmlDocument{}
opmlDocument.Version = "2.0"
opmlDocument.MinifluxNamespace = minifluxOPMLNamespace
opmlDocument.Header.Title = "Miniflux"
opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")
@@ -53,6 +54,24 @@ func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
ScraperRules: subscription.ScraperRules,
RewriteRules: subscription.RewriteRules,
UrlRewriteRules: subscription.UrlRewriteRules,
BlocklistRules: subscription.BlocklistRules,
KeeplistRules: subscription.KeeplistRules,
BlockFilterEntryRules: subscription.BlockFilterEntryRules,
KeepFilterEntryRules: subscription.KeepFilterEntryRules,
UserAgent: subscription.UserAgent,
Crawler: subscription.Crawler,
IgnoreHTTPCache: subscription.IgnoreHTTPCache,
FetchViaProxy: subscription.FetchViaProxy,
Disabled: subscription.Disabled,
NoMediaPlayer: subscription.NoMediaPlayer,
HideGlobally: subscription.HideGlobally,
AllowSelfSignedCertificates: subscription.AllowSelfSignedCertificates,
DisableHTTP2: subscription.DisableHTTP2,
IgnoreEntryUpdates: subscription.IgnoreEntryUpdates,
})
}
+87
View File
@@ -5,6 +5,7 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
import (
"bytes"
"strings"
"testing"
)
@@ -38,6 +39,92 @@ func TestSerialize(t *testing.T) {
}
}
func TestSerializeWithMinifluxSettings(t *testing.T) {
input := subcription{
Title: "Feed 1",
FeedURL: "http://example.org/feed/1",
SiteURL: "http://example.org/1",
CategoryName: "Category 1",
ScraperRules: `article [class^="content"]`,
RewriteRules: `replace("foo"|"bar")`,
UrlRewriteRules: `rewrite("^https://old"|"https://new")`,
BlocklistRules: "sponsored",
KeeplistRules: "important",
BlockFilterEntryRules: `EntryTitle=~"ad"`,
KeepFilterEntryRules: `EntryTitle=~"news"`,
UserAgent: "CustomAgent/1.0",
Crawler: true,
IgnoreHTTPCache: true,
FetchViaProxy: true,
Disabled: true,
NoMediaPlayer: true,
HideGlobally: true,
AllowSelfSignedCertificates: true,
DisableHTTP2: true,
IgnoreEntryUpdates: true,
}
output := serialize([]subcription{input})
if !strings.Contains(output, `xmlns:miniflux="https://miniflux.app/opml"`) {
t.Fatal("Miniflux OPML namespace is missing")
}
if !strings.Contains(output, `miniflux:crawler="true"`) {
t.Fatal("Miniflux settings are not serialized with the Miniflux namespace")
}
if strings.Contains(output, "cookie") {
t.Fatal("Sensitive feed settings should not be serialized")
}
feeds, err := parse(bytes.NewBufferString(output))
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
}
if feeds[0] != input {
t.Errorf("Round-trip failed:\ngot: %+v\nwant: %+v", feeds[0], input)
}
}
func TestSerializePreservesNewlinesInRules(t *testing.T) {
input := subcription{
Title: "Feed 1",
FeedURL: "http://example.org/feed/1",
SiteURL: "http://example.org/1",
CategoryName: "Category 1",
RewriteRules: "replace(\"foo\"|\"bar\")\nadd_youtube_video",
ScraperRules: "article.content\np.body",
BlockFilterEntryRules: "EntryTitle=~\"ad\"\nEntryURL=~\"click\"",
}
output := serialize([]subcription{input})
feeds, err := parse(bytes.NewBufferString(output))
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
}
if feeds[0].RewriteRules != input.RewriteRules {
t.Errorf("RewriteRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].RewriteRules, input.RewriteRules)
}
if feeds[0].ScraperRules != input.ScraperRules {
t.Errorf("ScraperRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].ScraperRules, input.ScraperRules)
}
if feeds[0].BlockFilterEntryRules != input.BlockFilterEntryRules {
t.Errorf("BlockFilterEntryRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].BlockFilterEntryRules, input.BlockFilterEntryRules)
}
}
func TestNormalizedCategoriesOrder(t *testing.T) {
var orderTests = []struct {
naturalOrderName string
+19
View File
@@ -10,4 +10,23 @@ type subcription struct {
FeedURL string
CategoryName string
Description string
// Miniflux-specific feed settings
ScraperRules string
RewriteRules string
UrlRewriteRules string
BlocklistRules string
KeeplistRules string
BlockFilterEntryRules string
KeepFilterEntryRules string
UserAgent string
Crawler bool
IgnoreHTTPCache bool
FetchViaProxy bool
Disabled bool
NoMediaPlayer bool
HideGlobally bool
AllowSelfSignedCertificates bool
DisableHTTP2 bool
IgnoreEntryUpdates bool
}
+2 -1
View File
@@ -5,6 +5,7 @@ package parser // import "miniflux.app/v2/internal/reader/parser"
import (
"encoding/xml"
"errors"
"io"
"unicode"
@@ -71,7 +72,7 @@ func detectJSONFormat(r io.ReadSeeker) (bool, error) {
for {
n, err := r.Read(buffer)
if n == 0 {
if err == io.EOF {
if errors.Is(err, io.EOF) {
return false, nil // No non-whitespace content found
}
return false, err
+32 -38
View File
@@ -14,6 +14,20 @@ import (
"github.com/tdewolff/minify/v2/html"
)
var htmlMinifier = newHTMLMinifier()
func newHTMLMinifier() *minify.M {
m := minify.New()
m.Add("text/html", &html.Minifier{
KeepEndTags: true,
KeepQuotes: true,
KeepComments: false,
KeepSpecialComments: false,
KeepDefaultAttrVals: false,
})
return m
}
// parseISO8601Duration parses a subset of ISO8601 durations, mainly for youtube video.
func parseISO8601Duration(duration string) (time.Duration, error) {
after, ok := strings.CutPrefix(duration, "PT")
@@ -22,58 +36,38 @@ func parseISO8601Duration(duration string) (time.Duration, error) {
}
var d time.Duration
num := ""
start := 0
for _, char := range after {
var val int
var err error
for i := 0; i < len(after); i++ {
var unit time.Duration
switch char {
switch after[i] {
case 'Y', 'W', 'D':
return 0, fmt.Errorf("the '%c' specifier isn't supported", char)
return 0, fmt.Errorf("the '%c' specifier isn't supported", after[i])
case 'H':
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Hour
num = ""
unit = time.Hour
case 'M':
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Minute
num = ""
unit = time.Minute
case 'S':
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Second
num = ""
unit = time.Second
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
num += string(char)
continue
default:
return 0, errors.New("invalid character in the period")
}
val, err := strconv.Atoi(after[start:i])
if err != nil {
return 0, err
}
d += time.Duration(val) * unit
start = i + 1
}
return d, nil
}
func minifyContent(content string) string {
m := minify.New()
// Options required to avoid breaking the HTML content.
m.Add("text/html", &html.Minifier{
KeepEndTags: true,
KeepQuotes: true,
KeepComments: false,
KeepSpecialComments: false,
KeepDefaultAttrVals: false,
})
if minifiedHTML, err := m.String("text/html", content); err == nil {
content = minifiedHTML
}
return content
// when an error occurs, String returns the original content.
ret, _ := htmlMinifier.String("text/html", content)
return ret
}
+9 -1
View File
@@ -21,7 +21,15 @@ func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed in
if isCJK(sanitizedContent[:truncationPoint]) {
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
}
return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))
return int(math.Ceil(float64(countWords(sanitizedContent)) / float64(defaultReadingSpeed)))
}
func countWords(s string) int {
n := 0
for range strings.FieldsSeq(s) {
n++
}
return n
}
func isCJK(text string) bool {
@@ -86,3 +86,14 @@ func BenchmarkEstimateReadingTime(b *testing.B) {
}
}
}
func TestCountWordsZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(10, func() {
for _, sample := range samples {
countWords(sample)
}
})
if allocs != 0 {
t.Errorf("countWords allocated %v times, expected 0", allocs)
}
}
@@ -76,6 +76,8 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
slog.String("entry_url", entryURL),
)
}
case "add_enclosure_links":
entry.Content = addEnclosureLinks(entry)
case "add_castopod_episode":
entry.Content = addCastopodEpisode(entryURL, entry.Content)
case "base64_decode":
@@ -15,6 +15,7 @@ import (
"unicode"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
nethtml "golang.org/x/net/html"
@@ -355,6 +356,24 @@ func addPDFLink(entryURL, entryContent string) string {
return entryContent
}
func addEnclosureLinks(entry *model.Entry) string {
var links strings.Builder
for _, enclosure := range entry.Enclosures {
if enclosure.URL == "" {
continue
}
enclosureURL := html.EscapeString(enclosure.URL)
links.WriteString(`<li><a href="` + enclosureURL + `">` + enclosureURL + `</a></li>`)
}
if links.Len() > 0 {
return entry.Content + "<hr/><ul>" + strings.TrimSpace(links.String()) + "</ul>"
}
return entry.Content
}
func replaceTextLinks(input string) string {
return textLinkRegex.ReplaceAllString(input, `<a href="${1}">${1}</a>`)
}
@@ -832,6 +832,60 @@ func TestRewriteAddCastopodEpisode(t *testing.T) {
}
}
func TestRewriteAddEnclosureLinks(t *testing.T) {
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `A title`,
Content: `Article Content<hr/><ul>` +
`<li><a href="https://example.org/episode.mp3?token=a&amp;b=c">https://example.org/episode.mp3?token=a&amp;b=c</a></li>` +
`<li><a href="https://example.org/video.mp4">https://example.org/video.mp4</a></li></ul>`,
Enclosures: model.EnclosureList{
{URL: "https://example.org/episode.mp3?token=a&b=c", MimeType: "audio/mpeg"},
{URL: "https://example.org/video.mp4", MimeType: "video/mp4"},
{URL: "", MimeType: "application/pdf"},
},
}
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `A title`,
Content: `Article Content`,
Enclosures: model.EnclosureList{
{URL: "https://example.org/episode.mp3?token=a&b=c", MimeType: "audio/mpeg"},
{URL: "https://example.org/video.mp4", MimeType: "video/mp4"},
{URL: "", MimeType: "application/pdf"},
},
}
ApplyContentRewriteRules(testEntry, `add_enclosure_links`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestRewriteAddEnclosureLinksWithoutEnclosureURL(t *testing.T) {
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `A title`,
Content: `Article Content`,
Enclosures: model.EnclosureList{
{URL: "", MimeType: "audio/mpeg"},
},
}
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `A title`,
Content: `Article Content`,
Enclosures: model.EnclosureList{
{URL: "", MimeType: "audio/mpeg"},
},
}
ApplyContentRewriteRules(testEntry, `add_enclosure_links`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestRewriteBase64Decode(t *testing.T) {
controlEntry := &model.Entry{
URL: "https://example.org/article",
+35 -10
View File
@@ -66,6 +66,10 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
}
}
// Track GUIDs already seen in this feed to disambiguate items from
// non-conformant feeds that reuse the same <guid> for every entry.
seenGUIDs := make(map[string]int)
for _, item := range r.rss.Channel.Items {
entry := model.NewEntry()
entry.Date = findEntryDate(&item)
@@ -105,9 +109,24 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
}
// Generate the entry hash.
//
// The RSS 2.0 spec requires <guid> to uniquely identify the item, but
// some feeds ship the same GUID for every entry. Keep the first
// occurrence stable (so existing stored entries still match) and
// disambiguate later collisions using the entry URL or, as a last
// resort, the item position.
switch {
case item.GUID.Data != "":
entry.Hash = crypto.SHA256(item.GUID.Data)
n := seenGUIDs[item.GUID.Data]
seenGUIDs[item.GUID.Data] = n + 1
switch {
case n == 0:
entry.Hash = crypto.SHA256(item.GUID.Data)
case entry.URL != "":
entry.Hash = crypto.SHA256(item.GUID.Data + "|" + entry.URL)
default:
entry.Hash = crypto.SHA256(item.GUID.Data + "|" + strconv.Itoa(n))
}
case entryURL != "":
entry.Hash = crypto.SHA256(entryURL)
default:
@@ -162,7 +181,8 @@ func findFeedAuthor(rssChannel *rssChannel) string {
}
func findFeedTags(rssChannel *rssChannel) []string {
tags := make([]string, 0)
itunesCategories := rssChannel.GetItunesCategories()
tags := make([]string, 0, len(rssChannel.Categories)+len(itunesCategories)+1)
for _, tag := range rssChannel.Categories {
tag = strings.TrimSpace(tag)
@@ -171,7 +191,7 @@ func findFeedTags(rssChannel *rssChannel) []string {
}
}
for _, tag := range rssChannel.GetItunesCategories() {
for _, tag := range itunesCategories {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
@@ -280,7 +300,8 @@ func findEntryAuthor(rssItem *rssItem) string {
}
func findEntryTags(rssItem *rssItem) []string {
tags := make([]string, 0)
mediaLabels := rssItem.MediaCategories.Labels()
tags := make([]string, 0, len(rssItem.Categories)+len(mediaLabels))
for _, tag := range rssItem.Categories {
tag = strings.TrimSpace(tag)
@@ -289,7 +310,7 @@ func findEntryTags(rssItem *rssItem) []string {
}
}
for _, tag := range rssItem.MediaCategories.Labels() {
for _, tag := range mediaLabels {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
@@ -300,10 +321,14 @@ func findEntryTags(rssItem *rssItem) []string {
}
func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool)
mediaThumbnails := rssItem.AllMediaThumbnails()
mediaContents := rssItem.AllMediaContents()
mediaPeerLinks := rssItem.AllMediaPeerLinks()
capacity := len(mediaThumbnails) + len(rssItem.Enclosures) + len(mediaContents) + len(mediaPeerLinks)
enclosures := make(model.EnclosureList, 0, capacity)
duplicates := make(map[string]bool, capacity)
for _, mediaThumbnail := range rssItem.AllMediaThumbnails() {
for _, mediaThumbnail := range mediaThumbnails {
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
if mediaURL == "" {
continue
@@ -356,7 +381,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
}
}
for _, mediaContent := range rssItem.AllMediaContents() {
for _, mediaContent := range mediaContents {
mediaURL := strings.TrimSpace(mediaContent.URL)
if mediaURL == "" {
continue
@@ -380,7 +405,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
}
}
for _, mediaPeerLink := range rssItem.AllMediaPeerLinks() {
for _, mediaPeerLink := range mediaPeerLinks {
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
if mediaURL == "" {
continue
+136
View File
@@ -2179,3 +2179,139 @@ func TestParseFeedWithIncorrectTTLValue(t *testing.T) {
t.Errorf("Incorrect TTL, got: %d", feed.TTL)
}
}
func TestParseEntriesWithDuplicateGUIDAndDistinctLinks(t *testing.T) {
// Some non-conformant feeds (e.g. fluentboards.com) ship the same <guid>
// for every item. The first occurrence must keep the historical
// SHA256(guid) hash so previously stored entries still match, while later
// duplicates are disambiguated using the entry URL.
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item A</title>
<link>https://example.org/a</link>
<guid isPermaLink="false">dup-guid</guid>
</item>
<item>
<title>Item B</title>
<link>https://example.org/b</link>
<guid isPermaLink="false">dup-guid</guid>
</item>
<item>
<title>Item C</title>
<link>https://example.org/a</link>
<guid isPermaLink="false">dup-guid</guid>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 3 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
expected := []string{
"22561495b53d916c228504600fda7c06cefc55a80395bbdf007102a6e9070d3f", // SHA256("dup-guid")
"3479bd288c08f8d2f8e992aa511a267ee50b18e22a4c475a4862c15c4d4f675b", // SHA256("dup-guid|https://example.org/b")
"8e4bb51d800e7a0db6b81b48d09af4711f8a25e00d09251e3d9f675e02bfe11e", // SHA256("dup-guid|https://example.org/a")
}
for i, want := range expected {
if feed.Entries[i].Hash != want {
t.Errorf("Entry %d: incorrect hash, got: %s, want: %s", i, feed.Entries[i].Hash, want)
}
}
// Sanity-check uniqueness across all three entries.
seen := make(map[string]bool)
for _, e := range feed.Entries {
if seen[e.Hash] {
t.Errorf("Duplicate hash across entries: %s", e.Hash)
}
seen[e.Hash] = true
}
}
func TestParseEntriesWithDuplicateGUIDAndNoLink(t *testing.T) {
// When colliding entries also lack a usable URL, we fall back to the
// position-based disambiguator. The site URL is used as the entry URL
// fallback (see findEntryURL handling), so the second entry hashes
// against that URL rather than the position.
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item A</title>
<guid isPermaLink="false">dup-guid</guid>
</item>
<item>
<title>Item B</title>
<guid isPermaLink="false">dup-guid</guid>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 2 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "22561495b53d916c228504600fda7c06cefc55a80395bbdf007102a6e9070d3f" {
t.Errorf("Entry 0: incorrect hash, got: %s", feed.Entries[0].Hash)
}
// Both items fall back to the channel link as their URL, so the second
// hash uses "dup-guid|https://example.org/".
if feed.Entries[1].Hash != "175d6d57a61533fb553c5d9bc3d52aa9092553a7e0ae473e3536c32c87cfa418" {
t.Errorf("Entry 1: incorrect hash, got: %s", feed.Entries[1].Hash)
}
if feed.Entries[0].Hash == feed.Entries[1].Hash {
t.Errorf("Hashes should differ for duplicate-GUID items")
}
}
func TestParseEntriesWithUniqueGUIDsAreUnchanged(t *testing.T) {
// Regression guard: feeds with unique GUIDs must keep the historical
// SHA256(guid) hashing so existing stored entries are not duplicated.
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item A</title>
<link>https://example.org/a</link>
<guid isPermaLink="false">guid-a</guid>
</item>
<item>
<title>Item B</title>
<link>https://example.org/b</link>
<guid isPermaLink="false">guid-b</guid>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 2 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "2b13a8de1741fb778d7e733e7a888088cb578d22e5ec6d40e0a88f82d4829cbd" {
t.Errorf("Entry 0: incorrect hash, got: %s", feed.Entries[0].Hash)
}
if feed.Entries[1].Hash != "33ce1b8657a81c6a49590313a36325e44683f27229e96faa690f9dc7b2a75d2a" {
t.Errorf("Entry 1: incorrect hash, got: %s", feed.Entries[1].Hash)
}
}
+34 -71
View File
@@ -148,53 +148,6 @@ var (
"x.com/share",
}
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
validURISchemes = []string{
// Most commong schemes on top.
"https:",
"http:",
// Then the rest.
"apt:",
"bitcoin:",
"callto:",
"dav:",
"davs:",
"ed2k:",
"facetime:",
"feed:",
"ftp:",
"geo:",
"git:",
"gopher:",
"irc:",
"irc6:",
"ircs:",
"itms-apps:",
"itms:",
"magnet:",
"mailto:",
"news:",
"nntp:",
"rtmp:",
"sftp:",
"sip:",
"sips:",
"skype:",
"spotify:",
"ssh:",
"steam:",
"svn:",
"svn+ssh:",
"tel:",
"webcal:",
"xmpp:",
// iOS Apps
"opener:", // https://www.opener.link
"hack:", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
}
dataAttributeAllowedPrefixes = []string{
"data:image/avif",
"data:image/apng",
@@ -350,15 +303,6 @@ func hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {
return true
}
func hasValidURIScheme(absoluteURL string) bool {
for _, scheme := range validURISchemes {
if strings.HasPrefix(absoluteURL, scheme) {
return true
}
}
return false
}
func isBlockedResource(absoluteURL string) bool {
for _, blockedURL := range blockedResourceURLSubstrings {
if strings.Contains(absoluteURL, blockedURL) {
@@ -504,7 +448,20 @@ func trackAttributes(s *mandatoryAttributesStruct, attributeName string) {
}
func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) (string, bool) {
htmlAttrs := make([]string, 0, len(attributes))
var htmlAttrs strings.Builder
// Rough estimate: most attributes are short; ~24 bytes (key + ="value") is
// a reasonable starting point. Avoids early grows for typical elements.
htmlAttrs.Grow(len(attributes) * 24)
// writeAttr appends key="value" to htmlAttrs, prefixing with a single
// space when not the first written attribute. value is HTML-escaped.
writeAttr := func(key, value string) {
htmlAttrs.WriteByte(' ')
htmlAttrs.WriteString(key)
htmlAttrs.WriteString(`="`)
htmlAttrs.WriteString(html.EscapeString(value))
htmlAttrs.WriteByte('"')
}
// Keep track of mandatory attributes for some tags
mandatoryAttributes := mandatoryAttributesStruct{false, false, false}
@@ -587,20 +544,24 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
continue
}
if !hasValidURIScheme(value) {
if !HasValidURIScheme(value) {
continue
}
// TODO use feedURL instead of baseURL twice.
parsedValueUrl, _ := url.Parse(value)
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil {
value = cleanedURL
// Skip the parse + RemoveTrackingParameters round trip when there
// is no query string to clean, which is common for <img>.
if strings.IndexByte(value, '?') >= 0 {
parsedValueUrl, _ := url.Parse(value)
// TODO use feedURL instead of baseURL twice.
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil {
value = cleanedURL
}
}
}
}
trackAttributes(&mandatoryAttributes, attribute.Key)
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
writeAttr(attribute.Key, value)
}
if !hasRequiredAttributes(&mandatoryAttributes, tagName) {
@@ -610,27 +571,29 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
if !isAnchorLink {
switch tagName {
case "a":
htmlAttrs = append(htmlAttrs, `rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`)
writeAttr("rel", "noopener noreferrer")
writeAttr("referrerpolicy", "no-referrer")
if sanitizerOptions.OpenLinksInNewTab {
htmlAttrs = append(htmlAttrs, `target="_blank"`)
writeAttr("target", "_blank")
}
case "video", "audio":
htmlAttrs = append(htmlAttrs, "controls")
htmlAttrs.WriteString(" controls")
case "iframe":
htmlAttrs = append(htmlAttrs, `sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`)
writeAttr("sandbox", "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox")
writeAttr("loading", "lazy")
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
if isYouTubeEmbed {
htmlAttrs = append(htmlAttrs, `referrerpolicy="strict-origin-when-cross-origin"`)
writeAttr("referrerpolicy", "strict-origin-when-cross-origin")
}
case "img":
htmlAttrs = append(htmlAttrs, `loading="lazy"`)
writeAttr("loading", "lazy")
}
}
return strings.Join(htmlAttrs, " "), true
return strings.TrimLeft(htmlAttrs.String(), " "), true
}
func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
@@ -647,7 +610,7 @@ func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
continue
}
if !hasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
if !HasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
continue
}
+2 -1
View File
@@ -4,6 +4,7 @@
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import (
"errors"
"io"
"strings"
@@ -19,7 +20,7 @@ func StripTags(input string) string {
for {
if tokenizer.Next() == html.ErrorToken {
err := tokenizer.Err()
if err == io.EOF {
if errors.Is(err, io.EOF) {
return buffer.String()
}
+73
View File
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import "strings"
// validURISchemes is the allowlist for URLs in sanitized feed body content.
// It is intentionally broad; stricter surfaces (redirects, template hrefs)
// should use urllib.IsAbsoluteURL / urllib.IsRelativePath instead.
//
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
var validURISchemes = []string{
// Most commong schemes on top.
"https",
"http",
// Then the rest.
"apt",
"bitcoin",
"callto",
"dav",
"davs",
"ed2k",
"facetime",
"feed",
"ftp",
"geo",
"git",
"gopher",
"irc",
"irc6",
"ircs",
"itms-apps",
"itms",
"magnet",
"mailto",
"news",
"nntp",
"rtmp",
"sftp",
"sip",
"sips",
"shortcuts",
"skype",
"spotify",
"ssh",
"steam",
"svn",
"svn+ssh",
"tel",
"webcal",
"xmpp",
// iOS Apps
"opener", // https://www.opener.link
"hack", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
}
// HasValidURIScheme reports whether the URL begins with an allowed scheme.
// The scheme comparison is case-insensitive per RFC 3986 §3.1.
func HasValidURIScheme(absoluteURL string) bool {
scheme, _, ok := strings.Cut(absoluteURL, ":")
if !ok || scheme == "" {
return false
}
for _, validScheme := range validURISchemes {
if strings.EqualFold(scheme, validScheme) {
return true
}
}
return false
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
import "testing"
func TestHasValidURIScheme(t *testing.T) {
scenarios := map[string]bool{
// Allowed: web schemes.
"http://example.org/article": true,
"https://example.org/article": true,
// Allowed: a sample of the broader feed-content schemes.
"mailto:author@example.org": true,
"magnet:?xt=urn:btih:abc": true,
"tel:+15551234567": true,
"ftp://example.org/file": true,
"feed:https://example.org/": true,
"webcal://example.org/cal": true,
// Rejected: schemes that enable script execution or local resource access.
"javascript:alert(1)": false,
"data:text/html,<script>alert(1)</script>": false,
"vbscript:msgbox(1)": false,
"file:///etc/passwd": false,
// Rejected: missing or malformed scheme.
"": false,
"example.org": false,
"/relative/path": false,
"//evil.example.org/path": false,
// Allowed: scheme matching is case-insensitive (RFC 3986 §3.1).
"HTTPS://example.org": true,
"MailTo:author@host": true,
"SVN+SSH://example.org": true,
// Rejected: case-insensitive match still rejects disallowed schemes.
"JavaScript:alert(1)": false,
"VBScript:msgbox(1)": false,
}
for input, expected := range scenarios {
t.Run(input, func(t *testing.T) {
if actual := HasValidURIScheme(input); actual != expected {
t.Errorf("HasValidURIScheme(%q) = %v, want %v", input, actual, expected)
}
})
}
}
+3 -2
View File
@@ -83,13 +83,14 @@ func findContentUsingCustomRules(page io.Reader, rules string) (baseURL string,
}
}
var buf strings.Builder
document.Find(rules).Each(func(i int, s *goquery.Selection) {
if content, err := goquery.OuterHtml(s); err == nil {
extractedContent += content
buf.WriteString(content)
}
})
return baseURL, extractedContent, nil
return baseURL, buf.String(), nil
}
func getPredefinedScraperRules(websiteURL string) string {
+6 -11
View File
@@ -44,7 +44,7 @@ func (s *Storage) Category(userID, categoryID int64) (*model.Category, error) {
err := s.db.QueryRow(query, userID, categoryID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
switch {
case err == sql.ErrNoRows:
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
@@ -61,7 +61,7 @@ func (s *Storage) FirstCategory(userID int64) (*model.Category, error) {
err := s.db.QueryRow(query, userID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
switch {
case err == sql.ErrNoRows:
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
@@ -78,7 +78,7 @@ func (s *Storage) CategoryByTitle(userID int64, title string) (*model.Category,
err := s.db.QueryRow(query, userID, title).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
switch {
case err == sql.ErrNoRows:
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
@@ -109,13 +109,8 @@ func (s *Storage) Categories(userID int64) (model.Categories, error) {
return categories, nil
}
// CategoriesWithFeedCount returns all categories with the number of feeds.
func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error) {
user, err := s.UserByID(userID)
if err != nil {
return nil, err
}
// CategoriesWithFeedCount returns all categories with the number of feeds, sorted according to sortOrder.
func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model.Categories, error) {
query := `
SELECT
c.id,
@@ -132,7 +127,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error
user_id=$2
`
if user.CategoriesSortingOrder == "alphabetical" {
if sortOrder == "alphabetical" {
query += `
ORDER BY
c.title ASC
+2 -1
View File
@@ -6,6 +6,7 @@ package storage // import "miniflux.app/v2/internal/storage"
import (
"context"
"database/sql"
"errors"
"golang.org/x/crypto/acme/autocert"
)
@@ -32,7 +33,7 @@ func (c *certificateCache) Get(ctx context.Context, key string) ([]byte, error)
query := `SELECT data::bytea FROM acme_cache WHERE key = $1`
var data []byte
err := c.storage.db.QueryRowContext(ctx, query, key).Scan(&data)
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
return nil, autocert.ErrCacheMiss
}
+2 -23
View File
@@ -5,6 +5,7 @@ package storage // import "miniflux.app/v2/internal/storage"
import (
"database/sql"
"errors"
"fmt"
"strings"
@@ -119,7 +120,6 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
enclosures
WHERE
id = $1
ORDER BY id ASC
`
row := s.db.QueryRow(query, enclosureID)
@@ -135,7 +135,7 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
&enclosure.MediaProgression,
)
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)
@@ -245,24 +245,3 @@ func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
return nil
}
// DeleteEnclosuresOfRemovedEntries deletes enclosures associated with entries marked as "removed".
func (s *Storage) DeleteEnclosuresOfRemovedEntries() (int64, error) {
query := `
DELETE FROM
enclosures
WHERE
enclosures.entry_id IN (SELECT id FROM entries WHERE status=$1)
`
result, err := s.db.Exec(query, model.EntryStatusRemoved)
if err != nil {
return 0, fmt.Errorf(`store: unable to delete enclosures from removed entries: %v`, err)
}
count, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf(`store: unable to get the number of rows affected while deleting enclosures from removed entries: %v`, err)
}
return count, nil
}
+100 -171
View File
@@ -16,6 +16,10 @@ import (
"github.com/lib/pq"
)
// ErrEntryTombstoned is returned when an entry cannot be created because its
// (feed_id, hash) pair has a tombstone recording a prior deletion.
var ErrEntryTombstoned = errors.New("store: entry is tombstoned")
// CountAllEntries returns the number of entries for each status in the database.
func (s *Storage) CountAllEntries() (map[string]int64, error) {
rows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)
@@ -27,7 +31,6 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
results := make(map[string]int64)
results[model.EntryStatusUnread] = 0
results[model.EntryStatusRead] = 0
results[model.EntryStatusRemoved] = 0
for rows.Next() {
var status string
@@ -40,28 +43,10 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
results[status] = count
}
results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead] + results[model.EntryStatusRemoved]
results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead]
return results, nil
}
// CountUnreadEntries returns the number of unread entries.
func (s *Storage) CountUnreadEntries(userID int64) int {
builder := s.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
builder.WithGloballyVisible()
n, err := builder.CountEntries()
if err != nil {
slog.Error("Unable to count unread entries",
slog.Int64("user_id", userID),
slog.Any("error", err),
)
return 0
}
return n
}
// NewEntryQueryBuilder returns a new EntryQueryBuilder
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
return NewEntryQueryBuilder(s, userID)
@@ -100,6 +85,9 @@ func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
// createEntry add a new entry.
func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
// The WHERE NOT EXISTS guard makes the tombstone check atomic with the insert, so a
// concurrent archive committing between an earlier existence check and this statement
// cannot bring a deleted entry back as unread.
query := `
INSERT INTO entries
(
@@ -117,22 +105,23 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
document_vectors,
tags
)
VALUES
(
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
now(),
setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
$13
)
SELECT
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
now(),
setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
$13
WHERE NOT EXISTS (
SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
)
RETURNING
id, status, created_at, changed_at
`
@@ -157,6 +146,9 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
&entry.CreatedAt,
&entry.ChangedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return ErrEntryTombstoned
}
if err != nil {
return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
}
@@ -245,7 +237,7 @@ func (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (
entryHash,
).Scan(&entryID)
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
@@ -289,9 +281,20 @@ func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (
}
func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
var result bool
s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, feedID, entryHash).Scan(&result)
return !result
// An entry is new only if it is neither stored nor tombstoned; otherwise
// callers (such as the crawler) would do expensive work on every refresh
// for items that will be discarded.
query := `
SELECT
EXISTS (
SELECT 1 FROM entries WHERE feed_id=$1 AND hash=$2
) OR EXISTS (
SELECT 1 FROM entry_tombstones WHERE feed_id=$1 AND hash=$2
)
`
var known bool
s.db.QueryRow(query, feedID, entryHash).Scan(&known)
return !known
}
func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
@@ -313,73 +316,8 @@ func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
return result
}
// cleanupRemovedEntriesNotInFeed deletes from the database entries marked as "removed" and not visible anymore in the feed.
func (s *Storage) cleanupRemovedEntriesNotInFeed(feedID int64, entryHashes []string) error {
// Acquire locks in id order and skip already-locked rows to avoid deadlocks with
// ClearRemovedEntriesContent, which also updates removed entries concurrently.
query := `
WITH to_delete AS (
SELECT id
FROM entries
WHERE
feed_id=$1 AND
status=$2 AND
NOT (hash=ANY($3))
ORDER BY id
FOR UPDATE SKIP LOCKED
)
DELETE FROM entries
USING to_delete
WHERE entries.id = to_delete.id
`
if _, err := s.db.Exec(query, feedID, model.EntryStatusRemoved, pq.Array(entryHashes)); err != nil {
return fmt.Errorf(`store: unable to remove entries not in feed: %v`, err)
}
return nil
}
// ClearRemovedEntriesContent clears the content fields of entries marked as "removed", keeping only their metadata.
func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
// Skip locked rows so this batch scrubber doesn't block or deadlock with the
// concurrent cleanup that deletes removed entries in the same table.
query := `
UPDATE
entries
SET
title='',
content=NULL,
url='',
author=NULL,
comments_url=NULL,
document_vectors=NULL
WHERE id IN (
SELECT id
FROM entries
WHERE status = $1 AND content IS NOT NULL
ORDER BY id ASC
FOR UPDATE SKIP LOCKED
LIMIT $2
)
`
result, err := s.db.Exec(query, model.EntryStatusRemoved, limit)
if err != nil {
return 0, fmt.Errorf(`store: unable to clear content from removed entries: %v`, err)
}
count, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf(`store: unable to get the number of rows affected while clearing content from removed entries: %v`, err)
}
return count, nil
}
// RefreshFeedEntries updates feed entries while refreshing a feed.
func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
entryHashes := make([]string, 0, len(entries))
for _, entry := range entries {
entry.UserID = userID
entry.FeedID = feedID
@@ -403,7 +341,10 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
}
} else {
err = s.createEntry(tx, entry)
if err == nil {
switch {
case errors.Is(err, ErrEntryTombstoned):
err = nil
case err == nil:
newEntries = append(newEntries, entry)
}
}
@@ -418,55 +359,43 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
}
entryHashes = append(entryHashes, entry.Hash)
}
go func() {
if err := s.cleanupRemovedEntriesNotInFeed(feedID, entryHashes); err != nil {
slog.Error("Unable to cleanup removed entries",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Any("error", err),
)
}
}()
return newEntries, nil
}
// ArchiveEntries changes the status of entries to "removed" after the interval (24h minimum).
// ArchiveEntries deletes entries older than the given interval and records tombstones so they are not re-ingested.
func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
if interval < 0 || limit <= 0 {
return 0, nil
}
query := `
UPDATE
entries
SET
status=$1
WHERE
id IN (
SELECT
id
FROM
entries
WHERE
status=$2 AND
starred is false AND
share_code='' AND
created_at < now () - $3::interval
ORDER BY
created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT $4
)
WITH to_delete AS (
SELECT id, feed_id, hash
FROM entries
WHERE
status=$1 AND
starred is false AND
share_code='' AND
created_at < now() - $2::interval
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT $3
), deleted AS (
DELETE FROM entries
USING to_delete
WHERE entries.id = to_delete.id
RETURNING entries.feed_id, entries.hash
)
INSERT INTO entry_tombstones (feed_id, hash)
SELECT feed_id, hash FROM deleted WHERE hash <> ''
ON CONFLICT (feed_id, hash) DO NOTHING
`
days := max(int(interval/(24*time.Hour)), 1)
result, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf("%d days", days), limit)
result, err := s.db.Exec(query, status, fmt.Sprintf("%d days", days), limit)
if err != nil {
return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
}
@@ -481,7 +410,6 @@ func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit in
// SetEntriesStatus update the status of the given list of entries.
func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
// Entries that have the model.EntryStatusRemoved status are immutable.
query := `
UPDATE
entries
@@ -490,37 +418,38 @@ func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string
changed_at=now()
WHERE
user_id=$2 AND
id=ANY($3) AND
status!=$4
id=ANY($3)
`
if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs), model.EntryStatusRemoved); err != nil {
if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)); err != nil {
return fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)
}
return nil
}
func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status string) (int, error) {
if err := s.SetEntriesStatus(userID, entryIDs, status); err != nil {
return 0, err
}
// SetEntriesStatusAndCountVisible updates the status of the given entries and returns how many are visible in global views.
func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64, status string) (int, error) {
query := `
WITH updated AS (
UPDATE entries
SET
status=$1,
changed_at=now()
WHERE
user_id=$2 AND
id=ANY($3)
RETURNING feed_id
)
SELECT count(*)
FROM entries e
JOIN feeds f ON (f.id = e.feed_id)
JOIN categories c ON (c.id = f.category_id)
WHERE e.user_id = $1
AND e.id = ANY($2)
AND NOT f.hide_globally
AND NOT c.hide_globally
FROM updated u
JOIN feeds f ON (f.id = u.feed_id)
JOIN categories c ON (c.id = f.category_id)
WHERE NOT f.hide_globally AND NOT c.hide_globally
`
row := s.db.QueryRow(query, userID, pq.Array(entryIDs))
visible := 0
if err := row.Scan(&visible); err != nil {
return 0, fmt.Errorf(`store: unable to query entries visibility %v: %v`, entryIDs, err)
var visible int
if err := s.db.QueryRow(query, status, userID, pq.Array(entryIDs)).Scan(&visible); err != nil {
return 0, fmt.Errorf(`store: unable to update entries status %v: %v`, entryIDs, err)
}
return visible, nil
}
@@ -564,19 +493,19 @@ func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
return nil
}
// FlushHistory changes all entries with the status "read" to "removed".
// FlushHistory deletes all read entries (non-starred, non-shared) and records tombstones to prevent re-ingestion.
func (s *Storage) FlushHistory(userID int64) error {
query := `
UPDATE
entries
SET
status=$1,
changed_at=now()
WHERE
user_id=$2 AND status=$3 AND starred is false AND share_code=''
WITH deleted AS (
DELETE FROM entries
WHERE user_id=$1 AND status=$2 AND starred is false AND share_code=''
RETURNING feed_id, hash
)
INSERT INTO entry_tombstones (feed_id, hash)
SELECT feed_id, hash FROM deleted WHERE hash <> ''
ON CONFLICT (feed_id, hash) DO NOTHING
`
_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)
if err != nil {
if _, err := s.db.Exec(query, userID, model.EntryStatusRead); err != nil {
return fmt.Errorf(`store: unable to flush history: %v`, err)
}
+5 -4
View File
@@ -5,6 +5,7 @@ package storage // import "miniflux.app/v2/internal/storage"
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
@@ -149,7 +150,7 @@ func (e *entryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID
var pID, nID sql.NullInt64
err = tx.QueryRow(query, e.args...).Scan(&pID, &nID)
switch {
case err == sql.ErrNoRows:
case errors.Is(err, sql.ErrNoRows):
return 0, 0, nil
case err != nil:
return 0, 0, fmt.Errorf("entry pagination: %v", err)
@@ -175,7 +176,7 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
)
switch {
case err == sql.ErrNoRows:
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, fmt.Errorf("fetching sibling entry: %v", err)
@@ -188,8 +189,8 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
func NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {
return &entryPaginationBuilder{
store: store,
args: []any{userID, "removed"},
conditions: []string{"e.user_id = $1", "e.status <> $2"},
args: []any{userID},
conditions: []string{"e.user_id = $1"},
entryID: entryID,
order: order,
direction: direction,
+22 -5
View File
@@ -25,6 +25,7 @@ type EntryQueryBuilder struct {
limit int
offset int
fetchEnclosures bool
excludeContent bool
}
// WithEnclosures fetches enclosures for each entry.
@@ -33,6 +34,14 @@ func (e *EntryQueryBuilder) WithEnclosures() *EntryQueryBuilder {
return e
}
// WithoutContent excludes the content column from the query results,
// replacing it with an empty string. This significantly reduces data
// transfer from PostgreSQL on list pages where content is not displayed.
func (e *EntryQueryBuilder) WithoutContent() *EntryQueryBuilder {
e.excludeContent = true
return e
}
// WithSearchQuery adds full-text search query to the condition.
func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
if query != "" {
@@ -207,7 +216,7 @@ func (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBui
// WithLimit set the limit.
func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {
if limit > 0 {
e.limit = limit
e.limit = min(limit, model.MaxEntryLimit)
}
return e
}
@@ -298,7 +307,7 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
e.comments_url,
e.author,
e.share_code,
e.content,
` + e.contentColumn() + `,
e.status,
e.starred,
e.reading_time,
@@ -344,9 +353,10 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
}
defer rows.Close()
entries := make(model.Entries, 0)
entryMap := make(map[int64]*model.Entry)
var entryIDs []int64
size := max(e.limit, 0)
entries := make(model.Entries, 0, size)
entryMap := make(map[int64]*model.Entry, size)
entryIDs := make([]int64, 0, size)
var totalCount int
for rows.Next() {
@@ -479,6 +489,13 @@ func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
return entryIDs, nil
}
func (e *EntryQueryBuilder) contentColumn() string {
if e.excludeContent {
return "'' AS content"
}
return "e.content"
}
func (e *EntryQueryBuilder) buildCondition() string {
return strings.Join(e.conditions, " AND ")
}
+13 -54
View File
@@ -7,7 +7,6 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"sort"
"time"
@@ -52,7 +51,7 @@ func (s *Storage) CheckedAt(userID, feedID int64) (time.Time, error) {
return result, nil
}
// CategoryFeedExists returns true if the given feed exists that belongs to the given category.
// CategoryFeedExists returns true if the given feed exists and belongs to the given category.
func (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {
var result bool
query := `SELECT true FROM feeds WHERE user_id=$1 AND category_id=$2 AND id=$3 LIMIT 1`
@@ -60,7 +59,7 @@ func (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {
return result
}
// FeedURLExists checks if feed URL already exists.
// FeedURLExists returns true if the given feed URL already exists for the user.
func (s *Storage) FeedURLExists(userID int64, feedURL string) bool {
var result bool
query := `SELECT true FROM feeds WHERE user_id=$1 AND feed_url=$2 LIMIT 1`
@@ -68,7 +67,7 @@ func (s *Storage) FeedURLExists(userID int64, feedURL string) bool {
return result
}
// AnotherFeedURLExists checks if the user a duplicated feed.
// AnotherFeedURLExists returns true if another feed with the same URL exists for the user.
func (s *Storage) AnotherFeedURLExists(userID, feedID int64, feedURL string) bool {
var result bool
query := `SELECT true FROM feeds WHERE id <> $1 AND user_id=$2 AND feed_url=$3 LIMIT 1`
@@ -76,7 +75,7 @@ func (s *Storage) AnotherFeedURLExists(userID, feedID int64, feedURL string) boo
return result
}
// CountAllFeeds returns the number of feeds in the database.
// CountAllFeeds returns the number of feeds keyed by enabled, disabled, and total.
func (s *Storage) CountAllFeeds() (map[string]int64, error) {
rows, err := s.db.Query(`SELECT disabled, count(*) FROM feeds GROUP BY disabled`)
if err != nil {
@@ -109,22 +108,6 @@ func (s *Storage) CountAllFeeds() (map[string]int64, error) {
return results, nil
}
// CountUserFeedsWithErrors returns the number of feeds with parsing errors that belong to the given user.
func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
if pollingParsingErrorLimit <= 0 {
pollingParsingErrorLimit = 1
}
query := `SELECT count(*) FROM feeds WHERE user_id=$1 AND parsing_error_count >= $2`
var result int
err := s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result)
if err != nil {
return 0
}
return result
}
// CountAllFeedsWithErrors returns the number of feeds with parsing errors.
func (s *Storage) CountAllFeedsWithErrors() (int, error) {
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
@@ -141,7 +124,7 @@ func (s *Storage) CountAllFeedsWithErrors() (int, error) {
return result, nil
}
// Feeds returns all feeds that belongs to the given user.
// Feeds returns all feeds that belong to the given user.
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
@@ -157,7 +140,7 @@ func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
return result, err
}
// FeedsWithCounters returns all feeds of the given user with counters of read and unread entries.
// FeedsWithCounters returns all feeds of the given user with read and unread entry counters.
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCounters()
@@ -165,7 +148,7 @@ func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
return getFeedsSorted(builder)
}
// FetchCounters returns read and unread count.
// FetchCounters returns the per-feed read and unread entry counts for the given user.
func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCounters()
@@ -173,7 +156,7 @@ func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
return model.FeedCounters{ReadCounters: reads, UnreadCounters: unreads}, err
}
// FeedsByCategoryWithCounters returns all feeds of the given user/category with counters of read and unread entries.
// FeedsByCategoryWithCounters returns all feeds in the given category for the given user with read and unread entry counters.
func (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.Feeds, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithCategoryID(categoryID)
@@ -214,7 +197,7 @@ func (s *Storage) WeeklyFeedEntryCount(userID, feedID int64) (int, error) {
return weeklyCount, nil
}
// FeedByID returns a feed by the ID.
// FeedByID returns the feed with the given ID.
func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
builder := NewFeedQueryBuilder(s, userID)
builder.WithFeedID(feedID)
@@ -442,7 +425,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
return nil
}
// UpdateFeedError updates feed errors.
// UpdateFeedError persists the parsing error fields for the given feed.
func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
query := `
UPDATE
@@ -471,45 +454,21 @@ func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
return nil
}
// RemoveFeed removes a feed and all entries.
// This operation can takes time if the feed has lot of entries.
// RemoveFeed removes the given feed along with its entries and enclosures.
func (s *Storage) RemoveFeed(userID, feedID int64) error {
rows, err := s.db.Query(`SELECT id FROM entries WHERE user_id=$1 AND feed_id=$2`, userID, feedID)
if err != nil {
return fmt.Errorf(`store: unable to get user feed entries: %v`, err)
}
defer rows.Close()
for rows.Next() {
var entryID int64
if err := rows.Scan(&entryID); err != nil {
return fmt.Errorf(`store: unable to read user feed entry ID: %v`, err)
}
slog.Debug("Deleting entry",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int64("entry_id", entryID),
)
if _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil {
return fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err)
}
}
if _, err := s.db.Exec(`DELETE FROM feeds WHERE id=$1 AND user_id=$2`, feedID, userID); err != nil {
return fmt.Errorf(`store: unable to delete feed #%d: %v`, feedID, err)
}
return nil
}
// ResetFeedErrors removes all feed errors.
// ResetFeedErrors clears the parsing error fields for all feeds.
func (s *Storage) ResetFeedErrors() error {
_, err := s.db.Exec(`UPDATE feeds SET parsing_error_count=0, parsing_error_msg=''`)
return err
}
// ResetNextCheckAt schedules all feeds to be checked immediately.
func (s *Storage) ResetNextCheckAt() error {
_, err := s.db.Exec(`UPDATE feeds SET next_check_at=now()`)
return err

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