Compare commits

...

146 Commits

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

|                     | Old (correlated)    | New (pre-aggregated)   |
|---------------------|---------------------|------------------------|
| Execution time      | 3.216 ms            | 3.123 ms               |
| Planning time       | 0.732 ms            | 0.778 ms               |
| Buffer hits         | 3,141               | 540                    |
| Index searches      | 1,237               | 1                      |
| Seq scans on feeds  | 10 (2 per category) | 2 (once each subquery) |
| Subquery executions | 10 (2x5 categories) | 2 (fixed)              |
2026-06-01 17:25:33 -07:00
Dennis Eriksen 2717336d2c docs: Specify unit of time for POLLING_FREQUENCY and list-separation for TRUSTED_REVERSE_PROXY_NETWORKS 2026-05-30 19:18:56 -07:00
gudvinr 9a774083ab refactor(response): use http.Header for header map
http.Header is built for headers, so use it for its intended purpose
2026-05-28 16:13:04 -07:00
dependabot[bot] 79381e6f9a build(deps): bump the gomod group with 4 updates
Bumps the gomod group with 4 updates: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn), [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/net](https://github.com/golang/net).


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Saves ~14 MB per million entries.
2026-05-16 19:32:43 -07:00
gudvinr 7628a214f3 fix(storage): remove possible SQL injection
As ORDER BY strings can't be included in parametrized queries, queries containing them are vulnerable to SQL injections.
2026-05-16 19:20:12 -07:00
jvoisin 20545d28e9 refactor(storage): remove a useless ORDER BY in GetEnclosure
The query selects a single row by primary key, there is no need to sort
anything.
2026-05-13 19:23:01 -07:00
dependabot[bot] 771977407c build(deps): bump the gomod group with 6 updates
Bumps the gomod group with 6 updates:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Before:

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

After:

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

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

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

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


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

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

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

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

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


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

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

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


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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 16:28:37 -07:00
Frédéric Guillot e0b1ec24e3 ci(build): compile binaries on pull requests without uploading 2026-04-06 16:19:40 -07:00
Frédéric Guillot bca47d7dfb ci(docker): trigger PR workflow on workflow file changes 2026-04-06 13:33:14 -07:00
Luca Andrea Rossi 19f72e8ea2 docs(docker-compose): add restart policy to postgres in basic example 2026-04-06 10:30:30 -07:00
288 changed files with 8117 additions and 4518 deletions
+20 -19
View File
@@ -3,29 +3,30 @@ updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
groups:
gomod:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
directories:
- "/packaging/docker/alpine"
- "/packaging/docker/distroless"
- "/packaging/debian"
- "/packaging/rpm"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
interval: "monthly"
groups:
docker:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "monthly"
groups:
github-actions:
patterns:
- "*"
+12 -3
View File
@@ -6,6 +6,14 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- '.github/workflows/build_binaries.yml'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- '**.go'
jobs:
build:
name: Build
@@ -13,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Golang
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
check-latest: true
@@ -24,7 +32,8 @@ jobs:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Mirror to Codeberg
+5 -5
View File
@@ -38,22 +38,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@v6
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{ matrix.language }}"
+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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
+16 -17
View File
@@ -9,6 +9,7 @@ on:
branches: [ main ]
paths:
- 'packaging/docker/**'
- '.github/workflows/docker.yml'
jobs:
docker-images:
name: Docker Images
@@ -18,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -37,7 +38,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -51,50 +52,48 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/login-action@v4
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v7
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@v9
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,11 +38,11 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
- name: Validate PR commits
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
@@ -31,13 +31,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Build RPM Package
+27
View File
@@ -0,0 +1,27 @@
name: Close Stale Pull Requests
permissions: read-all
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-pr-stale: 60
days-before-pr-close: 14
stale-pr-label: stale
stale-pr-message: >
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs within 14 days.
close-pr-message: >
This pull request has been automatically closed due to inactivity.
Please feel free to reopen it if you would like to continue working on it.
days-before-issue-stale: -1
days-before-issue-close: -1
+5 -5
View File
@@ -17,9 +17,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:9.5
image: postgres:11
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -44,9 +44,9 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Install Postgres client
+2 -2
View File
@@ -35,7 +35,7 @@ When reporting bugs:
### Requirements
- **Git**
- **Go >= 1.24**
- **Go >= 1.26**
- **PostgreSQL**
### Getting Started
@@ -103,7 +103,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
+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.
+73 -2
View File
@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}
// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryIDsContext(ctx, filter)
}
// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
if err != nil {
return nil, err
}
defer body.Close()
var result EntryIDsResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
if filter == nil {
return path
}
params := url.Values{}
if filter.Limit > 0 {
params.Set("limit", strconv.Itoa(filter.Limit))
}
if filter.Offset > 0 {
params.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.Starred != nil {
params.Set("starred", strconv.FormatBool(*filter.Starred))
}
if filter.Status != "" {
params.Set("status", filter.Status)
}
if len(params) == 0 {
return path
}
return path + "?" + params.Encode()
}
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
@@ -981,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
return err
}
// UpdateEntriesStarred updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
}
// UpdateEntriesStarredContext updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
@@ -1079,14 +1146,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
return &result, nil
}
// FlushHistory changes all entries with the status "read" to "removed".
// FlushHistory deletes all entries with the status "read".
func (c *Client) FlushHistory() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext changes all entries with the status "read" to "removed".
// FlushHistoryContext deletes all entries with the status "read".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
@@ -1231,6 +1298,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
values.Add("status", status)
}
for _, tag := range filter.Tags {
values.Add("tags", tag)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
+128
View File
@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
}
}
func TestUpdateEntriesStarred(t *testing.T) {
starred := true
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
expectFromJSON(t, req.Body, &struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}{
EntryIDs: []int64{1, 2},
Starred: &starred,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
expected := &Entry{
ID: 1,
@@ -1286,3 +1307,110 @@ func TestUpdateEnclosure(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
}
func boolPtr(b bool) *bool { return &b }
func TestEntryIDsNoFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 2,
EntryIDs: []int64{1, 2},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithPaginationFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 5,
EntryIDs: []int64{3},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStarredFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 1,
EntryIDs: []int64{42},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStatusFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 10,
EntryIDs: []int64{7, 8},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithCombinedFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 3,
EntryIDs: []int64{5},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
+31 -5
View File
@@ -10,9 +10,8 @@ import (
// Entry statuses.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
EntryStatusRemoved = "removed"
EntryStatusUnread = "unread"
EntryStatusRead = "read"
)
// User represents a user in the system.
@@ -75,8 +74,6 @@ type UserModificationRequest struct {
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
@@ -149,12 +146,15 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -175,6 +175,14 @@ type Feed struct {
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverEnabled bool `json:"pushover_enabled"`
PushoverPriority int `json:"pushover_priority"`
Icon *FeedIcon `json:"icon"`
}
// FeedCreationRequest represents the request to create a feed.
@@ -188,6 +196,7 @@ type FeedCreationRequest struct {
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -208,6 +217,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
@@ -223,6 +233,7 @@ type FeedModificationRequest struct {
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
NoMediaPlayer *bool `json:"no_media_player"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
@@ -321,6 +332,7 @@ type Filter struct {
CategoryID int64
FeedID int64
Statuses []string
Tags []string
GloballyVisible bool
}
@@ -330,6 +342,20 @@ type EntryResultSet struct {
Entries Entries `json:"entries"`
}
// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
type EntryIDsFilter struct {
Limit int
Offset int
Starred *bool
Status string
}
// EntryIDsResultSet represents the response when fetching entry ID lists.
type EntryIDsResultSet struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
// VersionResponse represents the version and the build information of the Miniflux instance.
type VersionResponse struct {
Version string `json:"version"`
+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.19.0
github.com/go-webauthn/webauthn v0.17.4
github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.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.53.0
golang.org/x/image v0.43.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
)
require (
github.com/go-webauthn/x v0.2.2 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
@@ -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.46.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.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.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.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+2 -1
View File
@@ -54,8 +54,9 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusAndStarredHandler)
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
+297 -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() {
@@ -2689,6 +2631,303 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
}
}
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entryID := result.Entries[0].ID
// Star the entry without changing its status.
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(entryID)
if err != nil {
t.Fatal(err)
}
if !entry.Starred {
t.Fatalf(`Expected entry to be starred`)
}
if entry.Status != miniflux.EntryStatusUnread {
t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
}
// Unstar the entry.
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
t.Fatal(err)
}
entry, err = regularUserClient.Entry(entryID)
if err != nil {
t.Fatal(err)
}
if entry.Starred {
t.Fatalf(`Expected entry to no longer be starred`)
}
}
func TestGetEntryIDsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
boolPtr := func(b bool) *bool { return &b }
// A new user should have no entries at all.
result, err := regularUserClient.EntryIDs(nil)
if err != nil {
t.Fatal(err)
}
if result.EntryIDs == nil {
t.Fatal(`Entry IDs should not be nil`)
}
if len(result.EntryIDs) != 0 {
t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
}
if result.Total != 0 {
t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
}
// Subscribe to a feed so there are entries.
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
allEntries, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
if len(allEntries.Entries) == 0 {
t.Fatal(`Expected feed to have entries`)
}
// Without filters, all entries should be returned.
result, err = regularUserClient.EntryIDs(nil)
if err != nil {
t.Fatal(err)
}
if len(result.EntryIDs) != allEntries.Total {
t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
}
if result.Total != allEntries.Total {
t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
}
// Filter by status=unread: all entries should be unread initially.
unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
if err != nil {
t.Fatal(err)
}
if len(unreadResult.EntryIDs) != allEntries.Total {
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
}
// Mark one entry as read and verify status filter results update.
firstEntryID := allEntries.Entries[0].ID
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
if err != nil {
t.Fatal(err)
}
if len(unreadResult.EntryIDs) != allEntries.Total-1 {
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
}
if unreadResult.Total != allEntries.Total-1 {
t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
}
for _, id := range unreadResult.EntryIDs {
if id == firstEntryID {
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
}
}
readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
if err != nil {
t.Fatal(err)
}
if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
}
// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
if allEntries.Total >= 2 {
pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(pagedResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
}
if pagedResult.Total != allEntries.Total-1 {
t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
}
// offset=1 should skip the first entry.
offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
if err != nil {
t.Fatal(err)
}
if len(offsetResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
}
if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
}
}
// Filter by starred=true: initially no starred entries.
starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
}
// Star the first entry and verify it appears in starred results.
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 1 {
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
}
if starredResult.Total != 1 {
t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
}
if starredResult.EntryIDs[0] != firstEntryID {
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
}
// The read starred entry should appear when filtering by starred=true (read status does not affect it).
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 1 {
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
}
// starred=false should exclude the starred entry.
notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
if err != nil {
t.Fatal(err)
}
for _, id := range notStarredResult.EntryIDs {
if id == firstEntryID {
t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
}
}
// Pagination with offset past the single starred result should return 0 entries but total 1.
pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
if err != nil {
t.Fatal(err)
}
if len(pagedStarred.EntryIDs) != 0 {
t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
}
if pagedStarred.Total != 1 {
t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
}
// Unstarring the entry should remove it from starred=true results.
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatal(err)
}
if len(starredResult.EntryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
}
if starredResult.Total != 0 {
t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
}
// Invalid starred value should return 400.
_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
if err == nil {
t.Fatal(`Expected error for invalid status parameter, got nil`)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+85
View File
@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
}
}
func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestParseEntryIDsParamsDefaults(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected default limit 10000, got %d`, limit)
}
if offset != 0 {
t.Fatalf(`Expected default offset 0, got %d`, offset)
}
}
func TestParseEntryIDsParamsCustomValues(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 500 {
t.Fatalf(`Expected limit 500, got %d`, limit)
}
if offset != 100 {
t.Fatalf(`Expected offset 100, got %d`, offset)
}
}
func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
}
}
func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
}
}
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
scenarios := []struct {
name string
+21 -12
View File
@@ -112,10 +112,20 @@ func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Reque
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
var categories model.Categories
var err error
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
if request.QueryBoolParam(r, "counts", false) {
user, userErr := h.store.UserByID(request.UserID(r))
if userErr != nil {
response.JSONServerError(w, r, userErr)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
} else {
categories, err = h.store.Categories(request.UserID(r))
}
@@ -158,15 +168,14 @@ func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request)
return
}
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithUserID(userID).
WithCategoryID(categoryID).
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
+2 -14
View File
@@ -22,7 +22,7 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -33,12 +33,6 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, enclosure)
@@ -62,7 +56,7 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -73,12 +67,6 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
response.JSONServerError(w, r, err)
+116 -60
View File
@@ -55,10 +55,9 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithFeedID(feedID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -76,10 +75,9 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithCategoryID(categoryID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -91,9 +89,8 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -164,26 +161,25 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
tags := request.QueryStringParamList(r, "tags")
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)
builder.WithSorting(order, direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
builder := h.store.NewEntryQueryBuilder(userID).
WithFeedID(feedID).
WithCategoryID(categoryID).
WithStatuses(statuses...).
WithSorting(order, direction).
WithOffset(offset).
WithLimit(limit).
WithTags(tags...).
WithEnclosures()
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
builder = builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
builder = configureFilters(builder, r)
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
@@ -193,26 +189,36 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
entries[i].Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
}
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
if entriesStatusUpdateRequest.Status != "" {
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
}
if entriesStatusUpdateRequest.Starred != nil {
if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
response.JSONServerError(w, r, err)
return
}
}
response.NoContent(w, r)
@@ -240,16 +246,14 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
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"))
return
}
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -290,11 +294,10 @@ 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()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -412,6 +415,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
@@ -447,11 +454,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
entry, err := entryBuilder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -473,9 +478,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
WithFeedID(entry.FeedID).
GetFeed()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -502,57 +507,108 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
if request.HasQueryParam(r, "starred") {
starredValue := request.QueryStringParam(r, "starred", "")
if starredValue != "true" && starredValue != "false" {
response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
return
}
}
if request.HasQueryParam(r, "status") {
statusValue := request.QueryStringParam(r, "status", "")
if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
return
}
}
limit, offset := parseEntryIDsParams(r)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithSorting("id", "DESC").
WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
WithOffset(offset)
if request.HasQueryParam(r, "starred") {
builder.WithStarred(request.QueryBoolParam(r, "starred", false))
}
if request.HasQueryParam(r, "status") {
builder.WithStatuses(request.QueryStringParam(r, "status", ""))
}
entryIDs, total, err := builder.GetEntryIDsWithCount()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entryIDs == nil {
entryIDs = []int64{}
}
response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
}
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
response.JSONAccepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) *storage.EntryQueryBuilder {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
builder = builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
builder = builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
builder.WithCategoryID(categoryID)
builder = builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
builder = builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
builder = builder.WithSearchQuery(searchQuery)
}
return builder
}
func parseEntryIDsParams(r *http.Request) (limit, offset int) {
limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
if limit <= 0 || limit > model.MaxEntryIDsLimit {
limit = model.MaxEntryIDsLimit
}
offset = request.QueryIntParam(r, "offset", 0)
return limit, offset
}
+8 -9
View File
@@ -76,14 +76,13 @@ func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithUserID(userID).
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -116,7 +115,7 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
response.JSONNotFound(w, r)
response.JSONServerError(w, r, err)
return
}
+5
View File
@@ -26,6 +26,11 @@ type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryIDsResponse struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
+11 -11
View File
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Re
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy).
WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(subscriptionDiscoveryRequest.Cookie).
WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password).
IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates).
DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
+30 -5
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"
@@ -21,6 +22,11 @@ func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
@@ -112,7 +118,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
return
}
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -127,7 +139,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -180,7 +198,7 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
@@ -202,7 +220,7 @@ func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request)
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
@@ -242,6 +260,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)
}
+4 -4
View File
@@ -25,24 +25,24 @@ func askCredentials() (string, string) {
reader := bufio.NewReader(os.Stdin)
username, err := reader.ReadString('\n')
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
printfAndExit("unable to read username: %w", err)
}
fmt.Print("Enter Password: ")
state, err := term.GetState(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
printfAndExit("unable to get terminal state: %w", err)
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
printfAndExit("unable to restore terminal state: %w", restoreErr)
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
printfAndExit("unable to read password: %w", err)
}
fmt.Print("\n")
+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),
)
}
}
+11 -6
View File
@@ -124,7 +124,7 @@ func Parse() {
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
printfAndExit("unable to open log file: %v", err)
}
defer logFileHandler.(*os.File).Close()
}
@@ -143,15 +143,15 @@ func Parse() {
}
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
printfAndExit("unable to generate binary files bundle: %v", err)
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
printfAndExit("unable to generate stylesheets bundle: %v", err)
}
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
printfAndExit("unable to generate javascript bundle: %v", err)
}
db, err := database.NewConnectionPool(
@@ -161,7 +161,7 @@ func Parse() {
config.Opts.DatabaseConnectionLifetime(),
)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
printfAndExit("unable to connect to database: %v", err)
}
defer db.Close()
@@ -231,7 +231,7 @@ func Parse() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
printfAndExit("unable to initialize proxy rotator: %v", err)
}
}
@@ -252,3 +252,8 @@ func printErrorAndExit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func printfAndExit(format string, args ...any) {
err := fmt.Errorf(format, args...)
printErrorAndExit(err)
}
+3 -3
View File
@@ -13,17 +13,17 @@ import (
func exportUserFeeds(store *storage.Storage, username string) {
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
printfAndExit("unable to find user: %w", err)
}
if user == nil {
printErrorAndExit(fmt.Errorf("user %q not found", username))
printfAndExit("user %q not found", username)
}
opmlHandler := opml.NewHandler(store)
opmlExport, err := opmlHandler.Export(user.ID)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
printfAndExit("unable to export feeds: %w", err)
}
fmt.Println(opmlExport)
+2 -3
View File
@@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"log/slog"
"net/http"
"time"
@@ -22,12 +21,12 @@ func doHealthCheck(healthCheckEndpoint string) {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
printfAndExit(`health check failure: %v`, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
printfAndExit(`health check failed with status code %d`, resp.StatusCode)
}
slog.Debug(`Health check is passing`)
+8 -9
View File
@@ -20,14 +20,13 @@ func refreshFeeds(store *storage.Storage) {
startTime := time.Now()
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(config.Opts.BatchSize())
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := store.NewBatchBuilder().
WithBatchSize(config.Opts.BatchSize()).
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
@@ -36,7 +35,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
var jobQueue = make(chan model.Job, nbJobs)
jobQueue := make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
+8 -7
View File
@@ -33,14 +33,15 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
jobs, err := store.NewBatchBuilder().
WithBatchSize(batchSize).
WithErrorLimit(errorLimit).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(limitPerHost).
FetchJobs()
if jobs, err := batchBuilder.FetchJobs(); err != nil {
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
+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")
}
}
+118 -4
View File
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"errors"
"miniflux.app/v2/internal/crypto"
)
@@ -352,7 +353,11 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
// This migration originally used md5(url), but it was changed to
// sha256 because PostgreSQL 18 disables MD5 in FIPS mode, which made
// fresh installs fail while replaying this migration. Existing
// installs that already ran it are migrated later on.
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`
_, err = tx.Exec(sql)
return err
},
@@ -483,7 +488,7 @@ var migrations = [...]func(tx *sql.Tx) error{
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -723,7 +728,12 @@ var migrations = [...]func(tx *sql.Tx) error{
}
// Create unique index
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
//
// This originally used md5(url), but it was changed to sha256 because
// PostgreSQL 18 disables MD5 in FIPS mode, which made fresh installs
// fail while replaying this migration. Existing installs that already
// ran it are migrated later on.
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`)
if err != nil {
return err
}
@@ -1081,7 +1091,7 @@ var migrations = [...]func(tx *sql.Tx) error{
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
@@ -1431,4 +1441,108 @@ 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
},
func(tx *sql.Tx) (err error) {
// entries_feed_idx is redundant: the unique constraint
// entries_feed_id_hash_key(feed_id, hash) and the explicit
// entries_feed_id_status_hash_idx(feed_id, status, hash) both
// cover feed_id-leading lookups, including FK cascade deletes.
//
// entries_user_status_idx is redundant: five three-column indexes
// share the same (user_id, status) prefix and serve every query
// that the two-column index could.
_, err = tx.Exec(`
DROP INDEX IF EXISTS entries_feed_idx;
DROP INDEX IF EXISTS entries_user_status_idx;
`)
return err
},
func(tx *sql.Tx) (err error) {
// PostgreSQL 18 disables MD5 when running in FIPS mode, which makes
// the unique index on enclosures relying on md5(url) unusable.
// Replace it with a SHA-256 based expression index.
_, err = tx.Exec(`
DROP INDEX IF EXISTS enclosures_user_entry_url_unique_idx;
CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx
ON enclosures (user_id, entry_id, encode(sha256(url::bytea), 'hex'));
`)
return err
},
}
+19 -24
View File
@@ -238,9 +238,8 @@ 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)
builder := h.store.NewEntryQueryBuilder(userID).
WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
@@ -250,8 +249,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
builder = builder.AfterEntryID(sinceID)
builder = builder.WithSorting("id", "ASC")
}
case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0)
@@ -259,14 +258,14 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC")
builder = builder.WithSorting("id", "DESC")
} else if maxID > 0 {
slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC")
builder = builder.BeforeEntryID(maxID)
builder = builder.WithSorting("id", "DESC")
}
case request.HasQueryParam(r, "with_ids"):
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
@@ -279,7 +278,7 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
itemIDs = append(itemIDs, itemID)
}
builder.WithEntryIDs(itemIDs)
builder = builder.WithEntryIDs(itemIDs...)
}
default:
slog.Debug("[Fever] Fetching oldest items",
@@ -293,9 +292,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
return
}
builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
result.Total, err = h.store.NewEntryQueryBuilder(userID).
CountEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -344,9 +342,9 @@ func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
rawEntryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStatuses(model.EntryStatusUnread).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -377,10 +375,9 @@ func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true)
entryIDs, err := builder.GetEntryIDs()
entryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStarred(true).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -412,11 +409,9 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
+61 -59
View File
@@ -236,26 +236,24 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
slog.Any("tags", tags),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs)
builder.WithoutStatus(model.EntryStatusRemoved)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(itemIDs...).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
}
n := 0
readEntryIDs := make([]int64, 0)
unreadEntryIDs := make([]int64, 0)
starredEntryIDs := make([]int64, 0)
unstarredEntryIDs := make([]int64, 0)
var readEntryIDs []int64
var unreadEntryIDs []int64
var starredEntryIDs []int64
var unstarredEntryIDs []int64
for _, entry := range entries {
if read, exists := tags[ReadStream]; exists {
if read && entry.Status == model.EntryStatusUnread {
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 +263,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)
}
}
@@ -344,9 +342,10 @@ func (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request)
return
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithUserAgent("", config.Opts.HTTPClientUserAgent())
var rssBridgeURL string
var rssBridgeToken string
@@ -650,13 +649,11 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
slog.Any("item_ids", itemIDs),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEnclosures().
WithEntryIDs(itemIDs...).
WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -1014,11 +1011,15 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
slog.String("user_agent", r.UserAgent()),
)
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
builder.WithStatus(model.EntryStatusUnread)
builder = builder.WithStatuses(model.EntryStatusUnread)
default:
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
@@ -1029,15 +1030,12 @@ 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)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1049,38 +1047,42 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStarred(true).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStatus(model.EntryStatusRead)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStatuses(model.EntryStatusRead).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1088,6 +1090,7 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
@@ -1096,7 +1099,7 @@ func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestMod
if err != nil {
return nil, 0, err
}
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
itemRefs := make([]itemRef, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
@@ -1120,33 +1123,32 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
return
}
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithFeedID(feedID)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithFeedID(feedID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder = builder.WithoutStatus(model.EntryStatusRead)
}
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
+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
+6 -6
View File
@@ -120,10 +120,10 @@ type contentItemOrigin struct {
}
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
builder := response.NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithBodyAsString("Unauthorized")
builder.Write()
response.NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("X-Reader-Google-Bad-Token", "true").
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithBodyAsString("Unauthorized").
Write()
}
-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)
}
}
+38 -17
View File
@@ -6,8 +6,11 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"net/http"
"strings"
"time"
@@ -22,14 +25,14 @@ type Builder struct {
w http.ResponseWriter
r *http.Request
statusCode int
headers map[string]string
headers http.Header
enableCompression bool
body any
}
// NewBuilder creates a new response builder.
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
}
// WithStatus uses the given status code to build the response.
@@ -40,7 +43,7 @@ func (b *Builder) WithStatus(statusCode int) *Builder {
// WithHeader adds the given HTTP header to the response.
func (b *Builder) WithHeader(key, value string) *Builder {
b.headers[key] = value
b.headers.Set(key, value)
return b
}
@@ -64,7 +67,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = "attachment; filename=" + filename
b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
return b
}
// WithInline suggests an inline filename for the current response.
func (b *Builder) WithInline(filename string) *Builder {
b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
return b
}
@@ -77,9 +86,11 @@ func (b *Builder) WithoutCompression() *Builder {
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
etag = normalizeETag(etag)
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public, immutable"
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
b.headers.Set("ETag", etag)
// max-age is required for the "immutable" directive to take effect: without
// it, browsers still revalidate content-hashed assets on every reload.
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
b.statusCode = http.StatusNotModified
@@ -113,24 +124,22 @@ func (b *Builder) Write() {
}
func (b *Builder) writeHeaders() {
b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer"
b.headers.Set("X-Content-Type-Options", "nosniff")
b.headers.Set("X-Frame-Options", "DENY")
b.headers.Set("Referrer-Policy", "no-referrer")
for key, value := range b.headers {
b.w.Header().Set(key, value)
}
maps.Copy(b.w.Header(), b.headers)
b.w.WriteHeader(b.statusCode)
}
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
b.headers["Vary"] = "Accept-Encoding"
b.headers.Set("Vary", "Accept-Encoding")
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.headers.Set("Content-Encoding", "br")
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
@@ -138,7 +147,7 @@ func (b *Builder) compress(data []byte) {
brotliWriter.Close()
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.headers.Set("Content-Encoding", "gzip")
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
@@ -146,7 +155,7 @@ func (b *Builder) compress(data []byte) {
gzipWriter.Close()
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.headers.Set("Content-Encoding", "deflate")
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
@@ -181,3 +190,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
}
+87 -2
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 {
@@ -155,7 +240,7 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public, immutable"
expectedHeader := "public, max-age=60, immutable"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
@@ -212,7 +297,7 @@ func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
}
if resp.Header.Get("Cache-Control") != "public, immutable" {
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
}
+47 -39
View File
@@ -4,24 +4,28 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"fmt"
"html"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/urllib"
)
// HTML creates a new HTML response with a 200 status code.
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder := NewBuilder(w, r).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
switch v := any(body).(type) {
case []byte:
builder.WithBodyAsBytes(v)
builder = builder.WithBodyAsBytes(v)
case string:
builder.WithBodyAsString(v)
builder = builder.WithBodyAsString(v)
}
builder.Write()
}
@@ -40,13 +44,13 @@ func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLBadRequest sends a bad request error to the client.
@@ -64,13 +68,13 @@ func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLForbidden sends a forbidden error to the client.
@@ -87,12 +91,12 @@ func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Access Forbidden")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Access Forbidden").
Write()
}
// HTMLNotFound sends a page not found error to the client.
@@ -109,16 +113,20 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Page Not Found")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Page Not Found").
Write()
}
// HTMLRedirect redirects the user to 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)
}
@@ -136,11 +144,11 @@ func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, co
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithHeader("Content-Range", contentRange)
builder.WithBodyAsString("Range Not Satisfiable")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusRequestedRangeNotSatisfiable).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithHeader("Content-Range", contentRange).
WithBodyAsString("Range Not Satisfiable").
Write()
}
+72
View File
@@ -183,6 +183,78 @@ 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",
`/\evil.example.org/path`,
`\evil.example.org\path`,
`/foo\bar`,
"ftp://example.org/file",
"",
}
for _, target := range scenarios {
t.Run(target, func(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
HTMLRedirect(w, r, target)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(`Expected 400 for %q, got %d`, target, resp.StatusCode)
}
if location := resp.Header.Get("Location"); location != "" {
t.Fatalf(`Expected no Location header for %q, got %q`, target, location)
}
})
}
}
func TestHTMLRequestedRangeNotSatisfiable(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
+38 -38
View File
@@ -22,10 +22,10 @@ func JSON(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONCreated sends a created response to the client.
@@ -36,19 +36,19 @@ func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusCreated).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONAccepted sends an accepted response to the client.
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusAccepted).
WithHeader("Content-Type", jsonContentTypeHeader).
Write()
}
// JSONServerError sends an internal error to the client.
@@ -66,11 +66,11 @@ func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONBadRequest sends a bad request error to the client.
@@ -88,11 +88,11 @@ func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONUnauthorized sends a not authorized error to the client.
@@ -109,11 +109,11 @@ func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access unauthorized"))).
Write()
}
// JSONForbidden sends a forbidden error to the client.
@@ -130,11 +130,11 @@ func JSONForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access forbidden"))).
Write()
}
// JSONNotFound sends a page not found error to the client.
@@ -151,11 +151,11 @@ func JSONNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
Write()
}
func generateJSONError(err error) []byte {
+3 -3
View File
@@ -17,7 +17,7 @@ const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-actio
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNoContent)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNoContent).
Write()
}
+4 -4
View File
@@ -7,8 +7,8 @@ import "net/http"
// Text writes a standard text response with a status 200 OK.
func Text(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", `text/plain; charset=utf-8`).
WithBodyAsString(body).
Write()
}
+9 -9
View File
@@ -7,17 +7,17 @@ import "net/http"
// XML writes a standard XML response with a status 200 OK.
func XML(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithBodyAsString(body).
Write()
}
// XMLAttachment forces the XML document to be downloaded by the web browser.
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithAttachment(filename).
WithBodyAsString(body).
Write()
}
+6 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -54,7 +55,11 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
return false
}
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
// Both checks have to be run to avoid leaking informations
// about the username and the password.
usernameCorrect := crypto.ConstantTimeCmp(username, config.Opts.MetricsUsername())
passwordCorrect := crypto.ConstantTimeCmp(password, config.Opts.MetricsPassword())
if !usernameCorrect || !passwordCorrect {
slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
+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
@@ -72,7 +72,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
+1 -1
View File
@@ -84,7 +84,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+1 -1
View File
@@ -58,7 +58,7 @@ func (c *Client) attachTags(entryID string) error {
return nil
}
tagItems := make([]tagItem, 0)
tagItems := make([]tagItem, 0, strings.Count(c.tags, ",")+1)
for tag := range strings.SplitSeq(c.tags, ",") {
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
+1 -1
View File
@@ -88,7 +88,7 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
}
defer response.Body.Close()
response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
+1 -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",
+1
View File
@@ -111,6 +111,7 @@ func TestTranslationFilePluralForms(t *testing.T) {
"id_ID": 1,
"it_IT": 2,
"ja_JP": 1,
"ko_KR": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"pl_PL": 3,
+4 -4
View File
@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
t.Errorf("Expected French translation %q, got %q", expected, result)
}
// Test with missing language (should use key as fallback with args applied)
// Test with missing language (should fall back to the untranslated key)
result = wrapper.Translate("invalid_lang")
expected = "error.test_key%!(EXTRA string=test message, int=404)"
expected = "error.test_key"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
localizedErr := NewLocalizedError("error.missing", "arg1")
result := localizedErr.String()
expected := "error.missing%!(EXTRA string=arg1)"
expected := "error.missing"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
// Test with missing language
result = localizedErr.Translate("invalid_lang")
expected = "error.permission%!(EXTRA string=admin panel)"
expected = "error.permission"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
+1
View File
@@ -17,6 +17,7 @@ var AvailableLanguages = map[string]string{
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
"ja_JP": "日本語",
"ko_KR": "한국어",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"pl_PL": "Polski",
+1 -1
View File
@@ -36,7 +36,7 @@ func getPluralForm(lang string, n int) int {
return 1
}
return 0
case "id_ID", "ja_JP":
case "id_ID", "ja_JP", "ko_KR":
return 0
case "pl_PL":
switch {
+8
View File
@@ -66,6 +66,14 @@ func TestPluralRules(t *testing.T) {
5: 0,
100: 0,
},
// Korean (ko_KR) - always form 0
"ko_KR": {
0: 0,
1: 0,
2: 0,
5: 0,
100: 0,
},
// Polish (pl_PL) - 3 forms
"pl_PL": {
1: 0, // n == 1
+32 -2
View File
@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...any) string {
return fmt.Sprintf(p.Print(key), args...)
return formatTranslation(p.Print(key), args...)
}
// Plural returns the translation of the given key by using the language plural form.
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
return formatTranslation(choices[index], args...)
}
}
return key
}
// formatTranslation skips extra arguments when the translation references no argument,
// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
func formatTranslation(format string, args ...any) string {
if !hasFormattingDirective(format) {
return fmt.Sprintf(format, []any{}...)
}
return fmt.Sprintf(format, args...)
}
// hasFormattingDirective reports whether the format should be handled with the
// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
// any other percent sequence, including a dangling "%".
func hasFormattingDirective(format string) bool {
for index := 0; index < len(format); index++ {
if format[index] != '%' {
continue
}
if index+1 >= len(format) {
return true
}
if format[index+1] == '%' {
index++ // skip the escaped percent
continue
}
return true
}
return false
}
+56
View File
@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
}
}
}
func TestPluralFormWithoutPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"ar_SA": translationDict{
plurals: map[string][]string{
// The Arabic dual omits the count by design.
"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
},
},
}
printer := NewPrinter("ar_SA")
if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
}
}
func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"media.completion": "Mark as read at 90%% completion",
},
},
}
got := NewPrinter("en_US").Printf("media.completion")
expected := "Mark as read at 90% completion"
if got != expected {
t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
}
}
func TestHasFormattingDirective(t *testing.T) {
tests := map[string]bool{
"دقيقتين": false,
"%d دقيقة": true,
"90%% done": false, // escaped percent consumes no argument
"%d of %s": true,
"": false,
"%": true,
}
for format, expected := range tests {
if got := hasFormattingDirective(format); got != expected {
t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
}
}
}
+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": "المقالات المشاركة",
+9 -6
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?",
@@ -175,7 +176,7 @@
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.ignore_entry_updates": "Updates ignorieren",
"form.feed.label.description": "Beschreibung",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
@@ -185,7 +186,7 @@
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keep_filter_entry_rules": "Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
@@ -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",
+20 -17
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?",
@@ -169,7 +170,7 @@
"form.feed.fieldset.network_settings": "Axustes da rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
"form.feed.label.apprise_service_urls": "Lista de URLs separadas por comas do servizo Apprise",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
"form.feed.label.category": "Categoría",
@@ -215,7 +216,7 @@
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar entradas a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
"form.integration.apprise_services_url": "Lista de URLs separadas por comas do servizo Apprise",
"form.integration.apprise_url": "URL de Apprise API",
"form.integration.betula_activate": "Gardar entradas en Betula",
"form.integration.betula_token": "Token de Betula",
@@ -304,9 +305,9 @@
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "Token (de proba)",
"form.integration.readeck_activate": "Gardar entradas en Readeck",
"form.integration.readeck_api_key": "Clave da Readeck API",
"form.integration.readeck_api_key": "Clave da API de Readeck",
"form.integration.readeck_endpoint": "URL de Readeck",
"form.integration.readeck_labels": "Etiquetas Readeck",
"form.integration.readeck_labels": "Etiquetas para Readeck",
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
@@ -328,25 +329,27 @@
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
"form.integration.telegram_bot_token": "Toke do Bot",
"form.integration.telegram_bot_token": "Token do Bot",
"form.integration.telegram_chat_id": "ID da parola",
"form.integration.telegram_topic_id": "ID do tema",
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
"form.integration.wallabag_client_secret": "Clave secreta en Wallabag",
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.wallabag_password": "Contrasinal en Wallabag",
"form.integration.wallabag_username": "Identificador en Wallabag",
"form.integration.wallabag_tags": "Etiquetas Wallabag",
"form.integration.wallabag_tags": "Etiquetas para Wallabag",
"form.integration.webhook_activate": "Activar Webhooks",
"form.integration.webhook_secret": "Clave secreta Webhooks",
"form.integration.webhook_url": "URL predeterminada Webhook",
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
"form.prefs.fieldset.application_settings": "Axustes da aplicación",
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "Lista de servidores de tipos de letra externos permitidos separados por espazos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ler artigos abrindo ligazóns externas",
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
@@ -364,11 +367,11 @@
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando acada o 90%% da reprodución",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente como lidas as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Para son/vídeo, marcar como lido ao chegar ao 90%% da reprodución",
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova pestana (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
"form.prefs.label.theme": "Decorado",
"form.prefs.label.timezone": "Zona horaria",
@@ -425,7 +428,8 @@
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.author": "Autoría:",
"page.about.authors_label": "Autoría:",
"page.about.authors_value": "Frédéric Guillot e colaboradoras",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
@@ -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": "共有エントリ",
+620
View File
@@ -0,0 +1,620 @@
{
"action.cancel": "취소",
"action.download": "다운로드",
"action.edit": "편집",
"action.home_screen": "홈 화면에 추가",
"action.import": "가져오기",
"action.login": "로그인",
"action.or": "또는",
"action.remove": "삭제",
"action.remove_feed": "이 피드 삭제",
"action.save": "저장",
"action.subscribe": "피드 구독",
"action.update": "업데이트",
"alert.account_linked": "외부 계정과 연동되었습니다!",
"alert.account_unlinked": "외부 계정과의 연동이 해제되었습니다!",
"alert.background_feed_refresh": "모든 피드를 백그라운드에서 새로 고치는 중입니다. 이 작업 중에도 Miniflux를 계속 사용할 수 있습니다.",
"alert.feed_error": "이 피드에 문제가 있습니다.",
"alert.no_starred": "현재 즐겨찾기 표시된 게시물이 없습니다.",
"alert.no_category": "카테고리가 없습니다.",
"alert.no_category_entry": "이 카테고리에는 게시물이 없습니다.",
"alert.no_feed": "구독 중인 피드가 없습니다.",
"alert.no_feed_entry": "이 피드에는 게시물이 없습니다.",
"alert.no_feed_in_category": "이 카테고리에는 구독 중인 피드가 없습니다.",
"alert.no_history": "현재 기록이 없습니다.",
"alert.no_search_result": "검색 결과가 없습니다.",
"alert.no_shared_entry": "공유된 게시물이 없습니다.",
"alert.no_tag_entry": "이 태그와 일치하는 게시물이 없습니다.",
"alert.no_unread_entry": "읽지 않은 게시물이 없습니다.",
"alert.no_user": "당신이 유일한 사용자입니다.",
"alert.prefs_saved": "설정이 정상적으로 저장되었습니다!",
"alert.too_many_feeds_refresh": [
"피드 새로고침 요청이 너무 많습니다. %d분 후 다시 시도해 주세요."
],
"confirm.loading": "실행 중…",
"confirm.no": "아니요",
"confirm.question": "진행하시겠습니까?",
"confirm.question.refresh": "강제로 새로 고치시겠습니까?",
"confirm.yes": "예",
"enclosure_media_controls.seek": "탐색:",
"enclosure_media_controls.seek.title": "%s초 이동",
"enclosure_media_controls.speed": "속도:",
"enclosure_media_controls.speed.faster": "빠르게",
"enclosure_media_controls.speed.faster.title": "%sx 빠르게",
"enclosure_media_controls.speed.reset": "초기화",
"enclosure_media_controls.speed.reset.title": "속도를 1x로 초기화",
"enclosure_media_controls.speed.slower": "느리게",
"enclosure_media_controls.speed.slower.title": "%sx 느리게",
"entry.starred.toast.off": "즐겨찾기를 해제했습니다",
"entry.starred.toast.on": "즐겨찾기로 설정했습니다",
"entry.starred.toggle.off": "즐겨찾기 해제",
"entry.starred.toggle.on": "즐겨찾기 설정",
"entry.comments.label": "댓글",
"entry.comments.title": "댓글 보기",
"entry.estimated_reading_time": [
"%d분 소요"
],
"entry.external_link.label": "외부 링크",
"entry.save.completed": "완료!",
"entry.save.label": "저장",
"entry.save.title": "이 게시물 저장",
"entry.save.toast.completed": "게시물이 저장되었습니다",
"entry.scraper.completed": "완료!",
"entry.scraper.label": "다운로드",
"entry.scraper.title": "원본 내용 가져오기",
"entry.share.label": "공유",
"entry.share.title": "이 게시물 공유",
"entry.shared_entry.label": "공유하기",
"entry.shared_entry.title": "공개 링크 열기",
"entry.state.loading": "불러오는 중…",
"entry.state.saving": "저장 중…",
"entry.status.mark_as_read": "읽음으로 표시",
"entry.status.mark_as_unread": "읽지 않음으로 표시",
"entry.status.title": "게시물 상태 변경",
"entry.status.toast.read": "읽음으로 표시했습니다",
"entry.status.toast.unread": "읽지 않음으로 표시했습니다",
"entry.tags.label": "태그:",
"entry.tags.more_tags_label": [
"태그 %d개"
],
"entry.unshare.label": "공유 해제",
"error.api_key_already_exists": "이 API 키는 이미 존재합니다.",
"error.bad_credentials": "사용자명 또는 비밀번호가 잘못되었습니다.",
"error.category_already_exists": "이 카테고리는 이미 존재합니다.",
"error.category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.database_error": "데이터베이스 오류: %v.",
"error.different_passwords": "비밀번호가 일치하지 않습니다.",
"error.duplicate_fever_username": "같은 Fever 사용자명이 이미 사용 중입니다!",
"error.duplicate_googlereader_username": "같은 Google Reader 사용자명이 이미 사용 중입니다!",
"error.duplicate_linked_account": "다른 사용자가 이미 이 서비스의 동일한 사용자와 연동되어 있습니다.",
"error.duplicated_feed": "이 피드는 이미 존재합니다.",
"error.empty_file": "이 파일은 비어 있습니다.",
"error.entries_per_page_invalid": "페이지당 게시물 수가 유효하지 않습니다.",
"error.feed_already_exists": "이 피드는 이미 존재합니다.",
"error.feed_category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_format_not_detected": "피드 형식을 감지할 수 없습니다: %v.",
"error.feed_invalid_blocklist_rule": "차단 목록 규칙이 유효하지 않습니다.",
"error.feed_invalid_keeplist_rule": "허용 목록 규칙이 유효하지 않습니다.",
"error.feed_mandatory_fields": "URL과 카테고리가 필요합니다.",
"error.feed_not_found": "이 피드는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_title_not_empty": "피드 제목은 비워 둘 수 없습니다.",
"error.feed_url_not_empty": "피드 URL은 비워 둘 수 없습니다.",
"error.fields_mandatory": "모든 항목을 입력해주세요.",
"error.http_bad_gateway": "잘못된 게이트웨이 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 나중에 다시 시도해 주세요.",
"error.http_body_read": "HTTP 본문을 읽을 수 없습니다: %v.",
"error.http_client_error": "HTTP 클라이언트 오류: %v.",
"error.http_cloudflare_challenge": "이 웹사이트는 Cloudflare 봇 챌린지(CAPTCHA 또는 JavaScript 검증)로 보호되어 있습니다. Miniflux는 이 챌린지를 자동으로 해결할 수 없습니다.",
"error.http_empty_response": "HTTP 응답이 비어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_empty_response_body": "HTTP 응답 본문이 비어 있습니다.",
"error.http_forbidden": "이 웹사이트에 대한 접근이 금지되어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_gateway_timeout": "게이트웨이 시간 초과로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_internal_server_error": "서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_not_authorized": "이 웹사이트에 대한 접근이 허용되지 않았습니다. 사용자명 또는 비밀번호가 올바르지 않을 수 있습니다.",
"error.http_resource_not_found": "요청한 리소스를 찾을 수 없습니다. URL을 확인해 주세요.",
"error.http_response_too_large": "HTTP 응답이 너무 큽니다. 전역 설정에서 HTTP 응답 크기 제한을 늘릴 수 있습니다. (서버 재시작 필요)",
"error.http_service_unavailable": "내부 서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. 문제는 Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_too_many_requests": "Miniflux가 이 웹사이트에 너무 많은 요청을 보냈습니다. 잠시 기다리거나 애플리케이션 설정을 변경해 주세요.",
"error.http_unexpected_status_code": "예상치 못한 HTTP 상태 코드(%d)로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.invalid_categories_sorting_order": "카테고리 표시 순서가 유효하지 않습니다.",
"error.invalid_default_home_page": "기본 시작 페이지가 유효하지 않습니다",
"error.invalid_display_mode": "웹 앱 표시 모드가 유효하지 않습니다.",
"error.invalid_entry_direction": "게시물 표시 방향이 유효하지 않습니다.",
"error.invalid_entry_order": "게시물 표시 순서가 유효하지 않습니다.",
"error.invalid_feed_proxy_url": "프록시 URL이 유효하지 않습니다.",
"error.invalid_feed_url": "피드 URL이 유효하지 않습니다.",
"error.invalid_gesture_nav": "제스처 내비게이션이 유효하지 않습니다.",
"error.invalid_language": "언어가 유효하지 않습니다.",
"error.invalid_site_url": "사이트 URL이 유효하지 않습니다.",
"error.invalid_theme": "테마가 유효하지 않습니다.",
"error.invalid_timezone": "시간대가 유효하지 않습니다.",
"error.network_operation": "네트워크 오류로 인해 Miniflux가 이 웹사이트에 도달할 수 없습니다: %v.",
"error.network_timeout": "이 웹사이트의 응답이 너무 느려 시간 초과되었습니다: %v",
"error.password_min_length": "비밀번호는 6자 이상이어야 합니다.",
"error.proxy_url_not_empty": "프록시 URL은 비워 둘 수 없습니다.",
"error.settings_block_rule_fieldname_invalid": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_block_rule_invalid_regex": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_block_rule_regex_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_block_rule_separator_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_invalid_domain_list": "도메인 목록이 유효하지 않습니다. 도메인은 공백으로 구분해 지정해 주세요.",
"error.settings_keep_rule_fieldname_invalid": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_keep_rule_invalid_regex": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_keep_rule_regex_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_keep_rule_separator_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_mandatory_fields": "사용자명, 테마, 언어, 시간대가 모두 필요합니다.",
"error.settings_media_playback_rate_range": "재생 속도가 범위를 벗어났습니다",
"error.settings_reading_speed_is_positive": "읽기 속도는 양의 정수여야 합니다.",
"error.site_url_not_empty": "사이트 URL은 비워 둘 수 없습니다.",
"error.subscription_not_found": "피드를 찾을 수 없습니다.",
"error.title_required": "제목이 필요합니다.",
"error.tls_error": "TLS 오류: %q. 필요한 경우 피드 설정에서 TLS 검증을 비활성화할 수 있습니다.",
"error.unable_to_create_api_key": "이 API 키를 만들 수 없습니다.",
"error.unable_to_create_category": "이 카테고리를 만들 수 없습니다.",
"error.unable_to_create_user": "이 사용자를 만들 수 없습니다.",
"error.unable_to_detect_rssbridge": "RSS-Bridge를 사용해 피드를 감지할 수 없습니다: %v.",
"error.unable_to_parse_feed": "이 피드를 파싱할 수 없습니다: %v.",
"error.unable_to_update_category": "이 카테고리를 업데이트할 수 없습니다.",
"error.unable_to_update_feed": "이 피드를 업데이트할 수 없습니다.",
"error.unable_to_update_user": "이 사용자를 업데이트할 수 없습니다.",
"error.unlink_account_without_password": "비밀번호를 설정하지 않으면 다시 로그인할 수 없습니다.",
"error.user_already_exists": "이 사용자는 이미 존재합니다.",
"error.user_mandatory_fields": "사용자명이 필요합니다.",
"error.linktaco_missing_required_fields": "LinkTaco API 토큰과 조직 슬러그가 필요합니다",
"form.api_key.label.description": "API키 설명",
"form.category.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.category.label.title": "제목",
"form.feed.fieldset.general": "일반",
"form.feed.fieldset.integration": "서드파티 서비스",
"form.feed.fieldset.network_settings": "네트워크 설정",
"form.feed.fieldset.rules": "규칙",
"form.feed.label.allow_self_signed_certificates": "자체 서명 인증서 또는 유효하지 않은 인증서 허용",
"form.feed.label.apprise_service_urls": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.feed.label.block_filter_entry_rules": "게시물 차단 규칙",
"form.feed.label.blocklist_rules": "정규식 기반 차단 필터",
"form.feed.label.category": "카테고리",
"form.feed.label.cookie": "Cookie 설정",
"form.feed.label.crawler": "게시물 본문도 함께 다운로드",
"form.feed.label.ignore_entry_updates": "업데이트된 게시물 무시",
"form.feed.label.description": "설명",
"form.feed.label.disable_http2": "핑거프린팅 회피를 위해 HTTP/2 비활성화",
"form.feed.label.disabled": "이 피드를 업데이트하지 않음",
"form.feed.label.feed_password": "피드 비밀번호",
"form.feed.label.feed_url": "피드 URL",
"form.feed.label.feed_username": "피드 사용자명",
"form.feed.label.fetch_via_proxy": "애플리케이션 수준에서 설정된 프록시 사용",
"form.feed.label.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.feed.label.ignore_http_cache": "HTTP 캐시 무시",
"form.feed.label.keep_filter_entry_rules": "게시물 허용 규칙",
"form.feed.label.keeplist_rules": "정규식 기반 보존 필터",
"form.feed.label.no_media_player": "미디어 기능 비활성화 (오디오/비디오)",
"form.feed.label.ntfy_activate": "게시물을 ntfy로 전송",
"form.feed.label.ntfy_default_priority": "ntfy 기본 우선순위",
"form.feed.label.ntfy_high_priority": "ntfy 높은 우선순위",
"form.feed.label.ntfy_low_priority": "ntfy 낮은 우선순위",
"form.feed.label.ntfy_max_priority": "ntfy 최대 우선순위",
"form.feed.label.ntfy_min_priority": "ntfy 최소 우선순위",
"form.feed.label.ntfy_priority": "ntfy 우선순위",
"form.feed.label.ntfy_topic": "ntfy 토픽(선택 사항)",
"form.feed.label.proxy_url": "프록시 URL",
"form.feed.label.pushover_activate": "게시물을 pushover.net으로 전송",
"form.feed.label.pushover_default_priority": "Pushover 기본 우선순위",
"form.feed.label.pushover_high_priority": "Pushover 높은 우선순위",
"form.feed.label.pushover_low_priority": "Pushover 낮은 우선순위",
"form.feed.label.pushover_max_priority": "Pushover 최대 우선순위",
"form.feed.label.pushover_min_priority": "Pushover 최소 우선순위",
"form.feed.label.pushover_priority": "Pushover 메시지 우선순위",
"form.feed.label.rewrite_rules": "본문 재작성 규칙",
"form.feed.label.scraper_rules": "본문 추출 규칙",
"form.feed.label.site_url": "사이트 URL",
"form.feed.label.title": "제목",
"form.feed.label.urlrewrite_rules": "URL 재작성 규칙",
"form.feed.label.user_agent": "기본 User Agent 덮어쓰기",
"form.feed.label.webhook_url": "Webhook URL 덮어쓰기",
"form.import.label.file": "OPML 파일",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "게시물을 archive.org로 푸시",
"form.integration.apprise_activate": "게시물을 Apprise로 전송",
"form.integration.apprise_services_url": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "게시물을 Betula에 저장",
"form.integration.betula_token": "Betula 토큰",
"form.integration.betula_url": "Betula 서버 URL",
"form.integration.cubox_activate": "게시물을 Cubox에 저장",
"form.integration.cubox_api_link": "Cubox API 링크",
"form.integration.discord_activate": "게시물을 Discord로 전송",
"form.integration.discord_webhook_link": "Discord Webhook 링크",
"form.integration.espial_activate": "Espial에 게시물 저장",
"form.integration.espial_api_key": "Espial API 키",
"form.integration.espial_endpoint": "Espial API 엔드포인트",
"form.integration.espial_tags": "Espial 태그",
"form.integration.fever_activate": "Fever API 활성화",
"form.integration.fever_endpoint": "Fever API 엔드포인트:",
"form.integration.fever_password": "Fever 비밀번호",
"form.integration.fever_username": "Fever 사용자명",
"form.integration.googlereader_activate": "Google Reader API 활성화",
"form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
"form.integration.googlereader_password": "Google Reader 비밀번호",
"form.integration.googlereader_username": "Google Reader 사용자명",
"form.integration.instapaper_activate": "Instapaper에 게시물 저장",
"form.integration.instapaper_password": "Instapaper 비밀번호",
"form.integration.instapaper_username": "Instapaper 사용자명",
"form.integration.karakeep_activate": "Karakeep에 게시물 저장",
"form.integration.karakeep_api_key": "Karakeep API 키",
"form.integration.karakeep_url": "Karakeep API 엔드포인트",
"form.integration.karakeep_tags": "Karakeep 태그",
"form.integration.linkace_activate": "게시물을 LinkAce에 저장",
"form.integration.linkace_api_key": "LinkAce API 키",
"form.integration.linkace_check_disabled": "링크 확인 비활성화",
"form.integration.linkace_endpoint": "LinkAce API 엔드포인트",
"form.integration.linkace_is_private": "링크를 비공개로 설정",
"form.integration.linkace_tags": "LinkAce 태그",
"form.integration.linkding_activate": "Linkding에 게시물 저장",
"form.integration.linkding_api_key": "Linkding API 키",
"form.integration.linkding_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.linkding_endpoint": "Linkding API 엔드포인트",
"form.integration.linkding_tags": "Linkding 태그",
"form.integration.linktaco_activate": "LinkTaco에 게시물 저장",
"form.integration.linktaco_api_token": "LinkTaco API 토큰",
"form.integration.linktaco_api_token_hint": "개인용 액세스 토큰 받기",
"form.integration.linktaco_org_slug": "조직 슬러그",
"form.integration.linktaco_tags": "태그(최대 10개, 쉼표로 구분)",
"form.integration.linktaco_tags_hint": "최대 10개의 태그, 쉼표로 구분",
"form.integration.linktaco_visibility": "공개 설정",
"form.integration.linktaco_visibility_public": "공개",
"form.integration.linktaco_visibility_private": "비공개",
"form.integration.linktaco_visibility_hint": "비공개 설정에는 유료 LinkTaco 계정이 필요합니다",
"form.integration.linkwarden_activate": "Linkwarden에 게시물 저장",
"form.integration.linkwarden_api_key": "Linkwarden API 키",
"form.integration.linkwarden_endpoint": "Linkwarden 기본 URL",
"form.integration.linkwarden_collection_id": "Linkwarden 컬렉션 ID",
"form.integration.matrix_bot_activate": "새 게시물을 Matrix로 전달",
"form.integration.matrix_bot_chat_id": "Matrix 룸 ID",
"form.integration.matrix_bot_password": "Matrix 사용자 비밀번호",
"form.integration.matrix_bot_url": "Matrix 서버 URL",
"form.integration.matrix_bot_user": "Matrix 사용자명",
"form.integration.notion_activate": "게시물을 Notion에 저장",
"form.integration.notion_page_id": "Notion 페이지 ID",
"form.integration.notion_token": "Notion 시크릿 토큰",
"form.integration.ntfy_activate": "게시물을 ntfy로 전송",
"form.integration.ntfy_api_token": "ntfy API 토큰(선택 사항)",
"form.integration.ntfy_icon_url": "ntfy 아이콘 URL(선택 사항)",
"form.integration.ntfy_internal_links": "클릭 시 내부 링크 사용(선택 사항)",
"form.integration.ntfy_password": "ntfy 비밀번호(선택 사항)",
"form.integration.ntfy_topic": "ntfy 토픽(피드에 설정되어 있지 않으면 기본값)",
"form.integration.ntfy_url": "ntfy URL(선택 사항, 기본값 ntfy.sh)",
"form.integration.ntfy_username": "ntfy 사용자명(선택 사항)",
"form.integration.nunux_keeper_activate": "Nunux Keeper에 게시물 저장",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 키",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 엔드포인트",
"form.integration.omnivore_activate": "Omnivore에 게시물 저장",
"form.integration.omnivore_api_key": "Omnivore API 키",
"form.integration.omnivore_url": "Omnivore API 엔드포인트",
"form.integration.pinboard_activate": "Pinboard에 게시물 저장",
"form.integration.pinboard_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.pinboard_tags": "Pinboard 태그",
"form.integration.pinboard_token": "Pinboard API 토큰",
"form.integration.pushover_activate": "게시물을 Pushover로 전송",
"form.integration.pushover_device": "Pushover 기기(선택 사항)",
"form.integration.pushover_prefix": "Pushover URL 접두사(선택 사항)",
"form.integration.pushover_token": "Pushover 앱 API 토큰",
"form.integration.pushover_user": "Pushover 사용자 키",
"form.integration.raindrop_activate": "게시물을 Raindrop에 저장",
"form.integration.raindrop_collection_id": "컬렉션 ID",
"form.integration.raindrop_tags": "태그(쉼표로 구분)",
"form.integration.raindrop_token": "(테스트) 토큰",
"form.integration.readeck_activate": "Readeck에 게시물 저장",
"form.integration.readeck_api_key": "Readeck API 키",
"form.integration.readeck_endpoint": "Readeck API 엔드포인트",
"form.integration.readeck_labels": "Readeck 라벨",
"form.integration.readeck_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.readeck_push_activate": "새 게시물을 자동으로 Readeck에 전송",
"form.integration.readwise_activate": "Readwise Reader에 게시물 저장",
"form.integration.readwise_api_key": "Readwise Reader 액세스 토큰",
"form.integration.readwise_api_key_link": "Readwise 액세스 토큰 받기",
"form.integration.rssbridge_activate": "구독 추가 시 RSS-Bridge 확인",
"form.integration.rssbridge_token": "RSS-Bridge 인증 토큰",
"form.integration.rssbridge_url": "RSS-Bridge 서버 URL",
"form.integration.shaarli_activate": "게시물을 Shaarli에 저장",
"form.integration.shaarli_api_secret": "Shaarli API 시크릿",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "게시물을 Shiori에 저장",
"form.integration.shiori_endpoint": "Shiori API 엔드포인트",
"form.integration.shiori_password": "Shiori 비밀번호",
"form.integration.shiori_username": "Shiori 사용자명",
"form.integration.slack_activate": "게시물을 Slack으로 전송",
"form.integration.slack_webhook_link": "Slack Webhook 링크",
"form.integration.telegram_bot_activate": "새 게시물을 Telegram 채팅으로 푸시",
"form.integration.telegram_bot_disable_buttons": "버튼 비활성화",
"form.integration.telegram_bot_disable_notification": "알림 비활성화",
"form.integration.telegram_bot_disable_web_page_preview": "웹 페이지 미리보기 비활성화",
"form.integration.telegram_bot_token": "봇 토큰",
"form.integration.telegram_chat_id": "채팅 ID",
"form.integration.telegram_topic_id": "토픽 ID",
"form.integration.wallabag_activate": "Wallabag에 게시물 저장",
"form.integration.wallabag_client_id": "Wallabag 클라이언트 ID",
"form.integration.wallabag_client_secret": "Wallabag 클라이언트 시크릿",
"form.integration.wallabag_endpoint": "Wallabag 기본 URL",
"form.integration.wallabag_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.wallabag_password": "Wallabag 비밀번호",
"form.integration.wallabag_username": "Wallabag 사용자명",
"form.integration.wallabag_tags": "Wallabag 태그",
"form.integration.webhook_activate": "Webhook 활성화",
"form.integration.webhook_secret": "Webhook 시크릿",
"form.integration.webhook_url": "기본 Webhook URL",
"form.prefs.fieldset.application_settings": "애플리케이션 설정",
"form.prefs.fieldset.authentication_settings": "비밀번호 인증",
"form.prefs.fieldset.google_authentication": "Google 인증",
"form.prefs.fieldset.oidc_authentication": "%s 인증",
"form.prefs.fieldset.global_feed_settings": "전역 피드 설정",
"form.prefs.fieldset.reader_settings": "리더 설정",
"form.prefs.help.external_font_hosts": "허용할 외부 폰트 호스트를 공백으로 구분해 지정합니다. 예: \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "외부 링크를 열어 게시물 읽기",
"form.prefs.label.categories_sorting_order": "카테고리 표시 순서",
"form.prefs.label.cjk_reading_speed": "한국어, 일본어, 중국어 읽기 속도 (문자/분)",
"form.prefs.label.custom_css": "사용자 지정 CSS",
"form.prefs.label.custom_js": "사용자 지정 JavaScript",
"form.prefs.label.default_home_page": "기본 시작 페이지",
"form.prefs.label.default_reading_speed": "다른 언어의 읽기 속도(단어/분)",
"form.prefs.label.display_mode": "프로그레시브 웹 앱(PWA) 표시 모드",
"form.prefs.label.entries_per_page": "페이지당 게시물 수",
"form.prefs.label.entry_order": "게시물 표시 순서 기준",
"form.prefs.label.entry_sorting": "게시물 표시 순서",
"form.prefs.label.entry_swipe": "터치스크린에서 스와이프 입력 활성화",
"form.prefs.label.external_font_hosts": "외부 폰트 호스트",
"form.prefs.label.gesture_nav": "게시물 간 이동 제스처",
"form.prefs.label.keyboard_shortcuts": "키보드 단축키 활성화",
"form.prefs.label.language": "언어",
"form.prefs.label.mark_read_manually": "수동으로 읽음 처리",
"form.prefs.label.mark_read_on_media_completion": "오디오/비디오 재생이 90%%에 도달하면 읽음 처리",
"form.prefs.label.mark_read_on_view": "표시할 때 게시물을 자동으로 읽음으로 표시",
"form.prefs.label.mark_read_on_view_or_media_completion": "표시할 때 읽음 처리. 오디오/비디오는 90%% 재생 시 읽음 처리",
"form.prefs.label.media_playback_rate": "오디오/비디오 재생 속도",
"form.prefs.label.open_external_links_in_new_tab": "외부 링크를 새 탭에서 열기(링크에 target=\"_blank\" 추가)",
"form.prefs.label.show_reading_time": "게시물 예상 읽기 시간 표시",
"form.prefs.label.theme": "테마",
"form.prefs.label.timezone": "시간대",
"form.prefs.select.alphabetical": "알파벳순",
"form.prefs.select.browser": "브라우저형",
"form.prefs.select.created_time": "게시물 가져온 시각",
"form.prefs.select.fullscreen": "전체 화면",
"form.prefs.select.minimal_ui": "미니멀 UI",
"form.prefs.select.none": "없음",
"form.prefs.select.older_first": "오래된 게시물 먼저",
"form.prefs.select.publish_time": "게시물 공개 시각",
"form.prefs.select.recent_first": "새 게시물 먼저",
"form.prefs.select.standalone": "독립형",
"form.prefs.select.swipe": "스와이프",
"form.prefs.select.tap": "더블 탭",
"form.prefs.select.unread_count": "읽지 않은 항목 수",
"form.submit.loading": "불러오는 중…",
"form.submit.saving": "저장 중…",
"form.user.label.admin": "관리자",
"form.user.label.confirmation": "비밀번호 확인",
"form.user.label.password": "비밀번호",
"form.user.label.username": "사용자명",
"menu.about": "소프트웨어 정보",
"menu.add_feed": "피드 구독",
"menu.add_user": "사용자 추가",
"menu.api_keys": "API 키",
"menu.categories": "카테고리",
"menu.create_api_key": "새 API 키 만들기",
"menu.create_category": "카테고리 만들기",
"menu.edit_category": "편집",
"menu.edit_feed": "편집",
"menu.export": "내보내기",
"menu.feed_entries": "게시물 목록",
"menu.feeds": "피드 목록",
"menu.flush_history": "기록 지우기",
"menu.history": "기록",
"menu.home_page": "홈페이지",
"menu.import": "가져오기",
"menu.integrations": "연동",
"menu.logout": "로그아웃",
"menu.mark_all_as_read": "모두 읽음으로 표시",
"menu.mark_page_as_read": "이 페이지를 읽음으로 표시",
"menu.preferences": "설정 정보",
"menu.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"menu.refresh_feed": "새로고침",
"menu.search": "검색",
"menu.sessions": "세션",
"menu.settings": "설정",
"menu.shared_entries": "공유 게시물",
"menu.show_all_entries": "모든 게시물 표시",
"menu.show_only_starred_entries": "즐겨찾기만 표시",
"menu.show_only_unread_entries": "읽지 않은 게시물만 표시",
"menu.starred": "즐겨찾기",
"menu.title": "메뉴",
"menu.unread": "읽지 않음",
"menu.users": "사용자 목록",
"page.about.authors_label": "작성자:",
"page.about.authors_value": "Frédéric Guillot 및 기여자",
"page.about.build_date": "빌드 일시:",
"page.about.credits": "저작권 표시",
"page.about.db_usage": "데이터베이스 크기:",
"page.about.git_commit": "Git 커밋:",
"page.about.global_config_options": "전역 설정 옵션",
"page.about.go_version": "Go 버전:",
"page.about.license": "라이선스:",
"page.about.postgres_version": "Postgres 버전:",
"page.about.title": "소프트웨어 정보",
"page.about.version": "버전:",
"page.add_feed.choose_feed": "피드 선택",
"page.add_feed.label.url": "피드 URL",
"page.add_feed.legend.advanced_options": "고급 설정",
"page.add_feed.no_category": "카테고리가 없습니다. 카테고리가 최소 1개 필요합니다.",
"page.add_feed.submit": "피드 탐색 및 추가",
"page.add_feed.title": "새 피드",
"page.api_keys.never_used": "사용된 적 없음",
"page.api_keys.table.actions": "액션",
"page.api_keys.table.created_at": "생성일",
"page.api_keys.table.description": "설명",
"page.api_keys.table.last_used_at": "마지막 사용",
"page.api_keys.table.token": "토큰",
"page.api_keys.title": "API 키",
"page.categories.entries": "게시물 목록",
"page.categories.feed_count": [
"피드가 %d개 있습니다."
],
"page.categories.feeds": "피드 목록",
"page.categories.no_feed": "피드가 없습니다.",
"page.categories.title": "카테고리",
"page.categories_count": [
"카테고리 %d개"
],
"page.category_label": "카테고리: %s",
"page.edit_category.title": "카테고리 편집: %s",
"page.edit_feed.etag_header": "ETag 헤더:",
"page.edit_feed.last_check": "마지막 확인:",
"page.edit_feed.last_modified_header": "Last-Modified 헤더:",
"page.edit_feed.last_parsing_error": "최근 파싱 오류",
"page.edit_feed.no_header": "없음",
"page.edit_feed.title": "피드 편집: %s",
"page.edit_user.title": "사용자 편집: %s",
"page.entry.attachments": "첨부 파일",
"page.feeds.error_count": [
"오류 %d개"
],
"page.feeds.last_check": "마지막 확인:",
"page.feeds.next_check": "다음 확인:",
"page.feeds.read_counter": "읽은 게시물 수",
"page.feeds.title": "피드 목록",
"page.footer.elevator": "페이지 맨 위로 올라가기",
"page.history.title": "기록",
"page.import.title": "가져오기",
"page.integration.bookmarklet": "북마크릿",
"page.integration.bookmarklet.help": "이 특별한 링크를 사용하면 브라우저에서 직접 웹사이트의 피드를 구독할 수 있습니다.",
"page.integration.bookmarklet.instructions": "이 링크를 브라우저 북마크로 드래그하세요.",
"page.integration.bookmarklet.name": "Miniflux에 추가",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 엔드포인트",
"page.integration.miniflux_api_password": "비밀번호",
"page.integration.miniflux_api_password_value": "계정 비밀번호",
"page.integration.miniflux_api_username": "사용자명",
"page.integrations.title": "연동",
"page.keyboard_shortcuts.close_modal": "모달 대화상자 닫기",
"page.keyboard_shortcuts.download_content": "원본 내용 다운로드",
"page.keyboard_shortcuts.go_to_bottom_item": "가장 아래 게시물로 이동",
"page.keyboard_shortcuts.go_to_categories": "카테고리",
"page.keyboard_shortcuts.go_to_feed": "피드",
"page.keyboard_shortcuts.go_to_feeds": "피드 목록",
"page.keyboard_shortcuts.go_to_history": "기록",
"page.keyboard_shortcuts.go_to_next_item": "다음 게시물",
"page.keyboard_shortcuts.go_to_next_page": "다음 페이지",
"page.keyboard_shortcuts.go_to_previous_item": "이전 게시물",
"page.keyboard_shortcuts.go_to_previous_page": "이전 페이지",
"page.keyboard_shortcuts.go_to_search": "검색 폼으로 이동",
"page.keyboard_shortcuts.go_to_settings": "설정",
"page.keyboard_shortcuts.go_to_starred": "즐겨찾기",
"page.keyboard_shortcuts.go_to_top_item": "맨 위 게시물로 이동",
"page.keyboard_shortcuts.go_to_unread": "읽지 않음",
"page.keyboard_shortcuts.mark_page_as_read": "현재 페이지의 게시물을 모두 읽음으로 표시",
"page.keyboard_shortcuts.open_comments": "댓글 링크 열기",
"page.keyboard_shortcuts.open_comments_same_window": "현재 탭에서 댓글 링크 열기",
"page.keyboard_shortcuts.open_item": "선택한 게시물 열기",
"page.keyboard_shortcuts.open_original": "원본 링크 열기",
"page.keyboard_shortcuts.open_original_same_window": "현재 탭에서 원본 링크 열기",
"page.keyboard_shortcuts.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"page.keyboard_shortcuts.remove_feed": "이 피드 삭제",
"page.keyboard_shortcuts.save_article": "게시물 저장",
"page.keyboard_shortcuts.scroll_item_to_top": "게시물이 상단에 오도록 스크롤",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "키보드 단축키 표시",
"page.keyboard_shortcuts.subtitle.actions": "작업",
"page.keyboard_shortcuts.subtitle.items": "게시물 간 이동",
"page.keyboard_shortcuts.subtitle.pages": "페이지 간 이동",
"page.keyboard_shortcuts.subtitle.sections": "섹션 이동",
"page.keyboard_shortcuts.title": "키보드 단축키",
"page.keyboard_shortcuts.toggle_star_status": "즐겨찾기 표시/해제",
"page.keyboard_shortcuts.toggle_entry_attachments": "첨부 파일 열기/닫기",
"page.keyboard_shortcuts.toggle_read_status_next": "읽음/읽지 않음 전환 후 다음 게시물로 이동",
"page.keyboard_shortcuts.toggle_read_status_prev": "읽음/읽지 않음 전환 후 이전 게시물로 이동",
"page.login.google_signin": "Google 계정으로 로그인",
"page.login.oidc_signin": "%s 계정으로 로그인",
"page.login.title": "로그인",
"page.login.webauthn_login": "패스키로 로그인",
"page.login.webauthn_login.error": "패스키로 로그인할 수 없음",
"page.new_api_key.title": "새 API 키",
"page.new_category.title": "새 카테고리",
"page.new_user.title": "새 사용자",
"page.offline.message": "오프라인입니다",
"page.offline.refresh_page": "페이지를 새로 고쳐 보세요",
"page.offline.title": "오프라인 모드",
"page.read_entry_count": [
"읽은 게시물 %d개"
],
"page.search.title": "검색 결과",
"page.sessions.table.actions": "작업",
"page.sessions.table.current_session": "현재 세션",
"page.sessions.table.date": "날짜",
"page.sessions.table.ip": "IP 주소",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "세션",
"page.settings.link_google_account": "Google 계정과 연동",
"page.settings.link_oidc_account": "%s 계정과 연동",
"page.settings.title": "설정",
"page.settings.unlink_google_account": "Google 계정과 연동 해제",
"page.settings.unlink_oidc_account": "%s 계정과 연동 해제",
"page.settings.webauthn.actions": "작업",
"page.settings.webauthn.added_on": "추가일",
"page.settings.webauthn.delete": [
"패스키 %d개 삭제"
],
"page.settings.webauthn.last_seen_on": "마지막 사용일",
"page.settings.webauthn.passkey_name": "패스키 이름",
"page.settings.webauthn.passkeys": "패스키 인증",
"page.settings.webauthn.register": "패스키 등록",
"page.settings.webauthn.register.error": "패스키를 등록할 수 없습니다",
"page.shared_entries.title": "공유 게시물",
"page.shared_entries_count": [
"공유 게시물 %d개"
],
"page.starred.title": "즐겨찾기",
"page.starred_entry_count": [
"즐겨찾기 표시된 게시물 %d개"
],
"page.total_entry_count": [
"총 게시물 %d개"
],
"page.unread.title": "읽지 않음",
"page.unread_entry_count": [
"읽지 않은 게시물 %d개"
],
"page.users.actions": "작업",
"page.users.admin.no": "아니오",
"page.users.admin.yes": "예",
"page.users.is_admin": "관리자",
"page.users.last_login": "마지막 로그인",
"page.users.never_logged": "로그인 기록 없음",
"page.users.title": "사용자 목록",
"page.users.username": "사용자명",
"page.webauthn_rename.title": "패스키 이름 변경",
"pagination.first": "처음",
"pagination.last": "마지막",
"pagination.next": "다음",
"pagination.previous": "이전",
"search.label": "검색",
"search.placeholder": "… 검색",
"search.submit": "검색",
"skip_to_content": "콘텐츠로 건너뛰기",
"time_elapsed.days": [
"%d일 전"
],
"time_elapsed.hours": [
"%d시간 전"
],
"time_elapsed.minutes": [
"%d분 전"
],
"time_elapsed.months": [
"%d개월 전"
],
"time_elapsed.not_yet": "미래",
"time_elapsed.now": "지금",
"time_elapsed.weeks": [
"%d주 전"
],
"time_elapsed.years": [
"%d년 전"
],
"time_elapsed.yesterday": "어제",
"tooltip.keyboard_shortcuts": "키보드 단축키: %s",
"tooltip.logged_user": "%s로 로그인 중"
}
@@ -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)
}
+9 -1
View File
@@ -11,11 +11,18 @@ 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
// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
// for the entry ID list endpoints.
const MaxEntryIDsLimit = 10000
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
@@ -73,6 +80,7 @@ type Entries []*Entry
type EntriesStatusUpdateRequest struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
Starred *bool `json:"starred"`
}
// EntryUpdateRequest represents a request to update an entry.
-10
View File
@@ -66,8 +66,6 @@ type UserModificationRequest struct {
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
@@ -134,14 +132,6 @@ func (u *UserModificationRequest) Patch(user *User) {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
if u.OpenIDConnectID != nil {
user.OpenIDConnectID = *u.OpenIDConnectID
}
if u.EntriesPerPage != nil {
user.EntriesPerPage = *u.EntriesPerPage
}
-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 {
+17 -9
View File
@@ -5,6 +5,7 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
@@ -19,7 +20,10 @@ type atom03Adapter struct {
}
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
feed := &model.Feed{
FeedURL: baseURL,
SiteURL: baseURL,
}
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
@@ -27,8 +31,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
@@ -37,8 +39,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
@@ -69,6 +69,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
@@ -81,17 +82,24 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
if value = strings.TrimSpace(value); value == "" {
continue
}
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
continue
}
entry.Date = parsedDate
break
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}

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