Compare commits

..

119 Commits

Author SHA1 Message Date
dependabot[bot] c4d54f87a8 build(deps): bump github.com/prometheus/client_golang in the gomod group
Bumps the gomod group with 1 update: [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang).


Updates `github.com/prometheus/client_golang` from 1.23.2 to 1.24.0
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 17:54:02 -07:00
Fred 1d10e7a089 fix(config): accept spaces and trailing commas in list option values
MEDIA_PROXY_RESOURCE_TYPES and TRUSTED_REVERSE_PROXY_NETWORKS were
validated by splitting the raw value without trimming, while the parser
trims items and skips empty ones. Values such as "image, video" or
"192.168.0.0/16, 10.0.0.0/8", and lists with a trailing comma, failed
startup even though the parser accepts them.

Validate the parsed list instead, and reject values that contain no
items at all so that a comma-only value cannot silently clear the
default media proxy resource types.
2026-07-21 21:22:35 -07:00
Fred 0e26f12426 fix(locale): protect translation catalog from concurrent map writes
getTranslationDict lazily populates the package-level defaultCatalog
map and runs on concurrent request goroutines via template functions,
flash messages, and error translation. Two concurrent requests for
languages not yet cached triggered Go's fatal "concurrent map writes"
and killed the daemon.

Guard the catalog with a sync.RWMutex using double-checked locking so
each language is loaded once. A failed load is no longer cached as an
empty dictionary, so unknown languages now return the error on every
call; both callers treat an error and an empty dictionary identically,
so rendered output is unchanged.

Add a regression test exercising concurrent lazy population; it fails
under the race detector on the previous implementation.
2026-07-21 20:54:36 -07:00
Fred e4e7dc55a7 fix(ui): keep proxy checkbox on subscription choose error re-render
The showChooseSubscriptionPage handler never set hasProxyConfigured on
the view, so when the add_subscription template was re-rendered after a
validation or feed creation error, the fetch_via_proxy checkbox
silently disappeared from the form. Set the flag during view setup,
matching the other subscription handlers.
2026-07-21 20:39:24 -07:00
Fred c8c414e8fb fix(worker): prevent panic when jobs are pushed during graceful shutdown
Pool.Shutdown() closed the job queue channel, but jobs can still be
pushed while workers are draining: the feed scheduler ticker goroutine
is never cancelled, and the UI and API refresh handlers push from
detached goroutines that outlive the HTTP server shutdown. Any of them
sending on the closed queue panicked the process mid-shutdown.

Keep the queue channel open and instead close a dedicated shutdown
channel, guarded by sync.Once. Push now selects between delivering a
job and the shutdown signal, discarding jobs once shutdown begins, and
workers select between the queue and the shutdown signal, so they
still finish their current job before Shutdown returns.
2026-07-21 20:33:50 -07:00
Fred 82537616f5 fix(integration): stop sending empty tags to Raindrop
strings.Split("", ",") returns [""], so an empty tag configuration
attached a single empty-string tag to every bookmark saved through the
Raindrop integration. Split the configured tags with SplitSeq, trim
whitespace, drop empty items, and omit the tags field entirely from the
payload when no tags are configured.
2026-07-21 20:30:53 -07:00
Fred 308e1f966c fix(config): reject non-positive SCHEDULER_ENTRY_FREQUENCY_FACTOR values
SCHEDULER_ENTRY_FREQUENCY_FACTOR was the only scheduler option without a
validator, so 0 was accepted at startup. With the entry_frequency polling
scheduler, the factor is used as part of a divisor in ScheduleNextCheck,
and a feed with weekly entries then triggered a division-by-zero panic
inside a background worker, crashing the daemon.

Require the factor to be >= 1, matching the validation of the other
scheduler options, so the misconfiguration fails at startup instead.
2026-07-20 20:54:54 -07:00
Fred fbbff63f2e fix(storage): clamp non-positive entry query limits to the maximum
WithLimit ignored a zero limit, so /v1/entries?limit=0 produced a query
without a SQL LIMIT and returned every matching entry, bypassing the
1000-entry cap. The official API client sends limit=0 for any filter
with an unset Limit field, making unbounded queries easy to trigger.

Clamp non-positive values to the maximum in both WithLimit and
WithLimitAndMaximum so every caller (REST API, Google Reader) stays
bounded, as intended by 0909323a.
2026-07-20 20:28:45 -07:00
Fred c342187b7e fix(handler): apply no_media_player option during feed creation
CreateFeed copied every other option from the feed creation request but
never NoMediaPlayer, so the flag was silently dropped and always stored
as false when creating a feed.
2026-07-20 20:17:34 -07:00
Fred 5c62899df9 fix(validator): allow clearing the feed proxy URL
An empty proxy_url in a feed modification request was rejected with
error.proxy_url_not_empty, so a proxy URL could never be unset once
configured. Accept the empty string to clear it, matching feed
creation, and only validate non-empty values.
2026-07-20 20:09:13 -07:00
Fred a21029dfb7 fix(validator): restrict entry_sorting_order to database enum values
ValidateUserModification reused ValidateEntryOrder, which accepts nine
sorting fields valid for the entry-listing order query parameter, while
the users.entry_order column is an entry_sorting_order enum allowing
only published_at and created_at. Requests such as
PUT /v1/users/{id} with entry_sorting_order=title passed validation and
failed in PostgreSQL, returning 500 instead of 400.

Validate the user preference against the enum values with a dedicated
validateEntrySortingOrder function and keep ValidateEntryOrder for the
entry-listing endpoint.
2026-07-20 19:57:03 -07:00
Fred 4237f8b090 fix(storage): return correct total when offset is beyond the last entry
The total returned by GetEntriesWithCount comes from count(*) OVER(),
which is carried on the returned rows. When the requested offset lands
past the last matching row, the query returns no rows and the total was
reported as 0 even though matching entries exist, breaking clients that
paginate until offset >= total.

Fall back to a separate CountEntries() query when the page is empty and
the offset is greater than zero. With offset 0 an empty result genuinely
means zero matches, so the single-query fast path is unchanged for
normal requests.

Add an integration test requesting the page at offset == total, which
must return no entries while keeping the same total.
2026-07-20 19:48:32 -07:00
Fred c119273b89 fix(storage): use <> ALL when counting remaining categories
The guard in RemoveAndReplaceCategoriesByName counted categories with
"title != ANY($2)", which is true whenever the title differs from at
least one element of the array. With two or more titles in the list,
every category matched — including the ones being deleted — so the
"at least 1 category must remain" check could pass even when the
deletion would remove all of the user's categories, leaving feeds
with a NULL category. Using "title <> ALL($2)" counts only the
categories that would actually survive the deletion.
2026-07-20 19:40:11 -07:00
Fred 92057dde56 fix(server): restrict unix socket permissions to 0660
The listening Unix socket was created world-writable (0666), allowing
any local user to connect. Restrict it to the owner and group.

Deployments where the reverse proxy runs as a different user now need
that user to share a group with the Miniflux process.
2026-07-20 18:15:02 -07:00
Fred 4d84eee221 fix(api): scope icon lookup by user in icons endpoint
The GET /v1/icons/{iconID} endpoint fetched icons by their internal
numeric identifier without any user scoping, allowing any authenticated
user to enumerate icon IDs and read favicon data associated with feeds
owned by other users on the same instance.

Rename IconByID to IconByUserAndIconID and gate the lookup on an EXISTS
check against feeds owned by the requesting user, so unauthorized icon
IDs return 404. Add integration tests covering cross-user access and
inexisting icon IDs.
2026-07-19 20:26:49 -07:00
Dave Marquard aa509b8802 feat(ui): use SVG for browser favicon 2026-07-17 21:07:08 -07:00
Ingmar Stein 56eb812b29 feat(server): reload TLS certificates on SIGHUP 2026-07-16 20:29:54 -07:00
jvoisin 69a788ab35 feat(finder): find github feeds 2026-07-16 20:21:15 -07:00
jvoisin 706a92e700 refactor(integration): factorize JSON request construction
Every integration talking to a JSON API hand-rolled the same dance:
json.Marshal the payload, build an *http.Request, set the Content-Type and
User-Agent headers, then run it through NewClientWithOptions. This is
near-identical copy-paste, so any change done to how requests are done had to
be repeated in each one.

This commit introduces a small request builder in internal/http/client and
route every integration through it:

```
client.NewRequestBuilder(endpoint).
	WithMethod(http.MethodPost).
	WithJSON(payload).
	WithHeaders(extraHeaders).
	Do()
```

Centralizing the logic also evens out disparities that had crept in between
integrations:

- private networks are now blocked everywhere, honoring
  INTEGRATION_ALLOW_PRIVATE_NETWORKS; notion previously did not block them;
- request errors are wrapped consistently with %w instead of a mix of %v/%w;
- the per-integration defaultClientTimeout constant is replaced by a single
  shared default in the client package;
- the Content-Type and Miniflux User-Agent headers are always set.

Reviewed-By: gudvinr
2026-07-16 19:53:35 -07:00
jvoisin 0939195966 perf(tests): speed up fetcher timeout test
TestRequestBuilder_TimeoutConfiguration dominated the whole test suite
at ~2s. The handler slept a fixed 2s, and httptest.Server.Close() blocks
until in-flight handlers finish, so the package always paid the full
sleep even though the client timed out at 1s.

Block the handler on <-r.Context().Done() instead: when the client times
out and drops the connection, the request context is cancelled and the
handler returns immediately, so Close() no longer waits. Shrink the
timeout to 100ms as well.
2026-07-16 19:24:13 -07:00
jvoisin ff01425686 test(api): run integration tests in parallel
Every integration tests in api_integration_test.go run sequentially against a
live server and are network-bound, taking around one minutes on my machine.

Each test is already isolated: it creates its own random-username user
and operates through that user's own client, with feeds/categories/
entries scoped per user. The shared admin client is only used read-only
or on error-path tests that create no durable state, and no test asserts
global counts, so the suite is safe to parallelize.

Add t.Parallel() to every test. No t.Setenv/Chdir or t.Run subtests are
present, so nothing conflicts with parallel execution.

It speeds things up from ~60s to ~7s on my machine. I ran the full testsuite a
handful of times just to be sure™, but didn't get any flakiness.
2026-07-16 19:21:07 -07:00
Fred 79d920bc1a fix(database): skip orphaned entries in migration v127
Databases restored from a dump without foreign key enforcement can
contain entries whose feed no longer exists. Migration v127 copied
those rows into entry_tombstones, violating its feed_id foreign key
and aborting the upgrade at schema version 126.

Only backfill tombstones for feeds that still exist: a tombstone for
a deleted feed is never consulted, and the orphaned rows are removed
by the DELETE that follows.

Fixes #4432
2026-07-13 20:23:11 -07:00
TowyTowy cf5ae57d9a fix(rewrite): keep content order when remove_tables unwraps tables
The remove_tables rule unwrapped each table element by appending its
inner HTML to the end of the parent node and then removing the element.
Any content located after the table (and the table content itself) was
therefore moved to the bottom of the entry instead of staying in place,
which reordered the article.

Replace the append-then-remove with an in-place ReplaceWithHtml so the
unwrapped content keeps its original document order. The existing
TestRewriteRemoveTables still passes because its content is fully nested
in a single root table; a new test with content surrounding the table
covers the regression.

Fixes #3110

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:47:37 -07:00
Saleh 070bc9ef3d fix(storage): respect category hide_globally when marking all as read
The "Mark all as read" button on the Unread page calls
MarkGloballyVisibleFeedsAsRead, which only filtered on feeds.hide_globally
and ignored the feed's category. Entries belonging to a category marked as
hidden from the global unread list were therefore marked as read even though
they are not shown on that page.

Join the categories table and exclude both feeds and categories that are
hidden globally, matching the visibility rules already used by the unread
query builder, the pagination builder and the navigation unread counter.

Fixes #4444
2026-07-09 20:21:34 -07:00
jvoisin e7888e3d43 perf(ui): defer off-screen layout/paint on entry and feed list rows
Miniflux' default user.EntriesPerPage value is 100, so people with a lot of
feeds/feed items might have 100 items displayed by default, which is a ton of
DOM elements: multiple inline SVG icons, two <button>, optional <time>,
optional reading-time chip, category tag, … the browser had to lay out and
paint all of it on initial render even though there are only a handful of rows
visible at once.

Setting content-visibility: auto on .entry-item and .feed-item lets
the browser skip layout, paint, and accessibility-tree work for rows
that fall outside the viewport, doing it lazily as the user scrolls.
contain-intrinsic-size: auto reserves a placeholder using each row's
last rendered size so the scrollbar and anchor scrolling stay
consistent after first measurement. The value 100px was picked as on my
machine/screen/…, a feed-item is a bit less than 100px, and an entry-item is
a bit more than 80px.

This doesn't introduced any regressions in keyboard shortcuts, both from
miniflux and from the browser. The two CSS properties are supported in current
modern browsers, and are simply ignored in those that don't.

Sources used:

- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/content-visibility
- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/contain-intrinsic-size
2026-07-09 19:51:25 -07:00
dependabot[bot] 8528e5e650 build(deps): bump the gomod group with 6 updates
Bumps the gomod group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) | `3.19.0` | `3.20.0` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.53.0` | `0.54.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.43.0` | `0.44.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.56.0` | `0.57.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.44.0` | `0.45.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.38.0` | `0.40.0` |


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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 19:36:49 -07:00
jvoisin e43381d17a perf(sanitizer): avoid Token() allocations in stripIter
Tokenizer.Token() allocates a Token struct and parses the full
attribute slice for every start tag, even though stripIter only
consumes text tokens. Switch to checking the TokenType from Next()
and calling Text() only for text tokens.

Shared by StripTags and TruncateHTML, both called per entry:

  StripTags     247KB -> 71KB/op (-71%), 12623 -> 4423 allocs (-65%)
  TruncateHTML  19KB  -> 7.9KB/op (-59%),   819 ->  297 allocs (-64%)

Both roughly 2x faster on tag-heavy input.
2026-07-07 21:33:04 -07:00
Aditya Raj Singh 5ebdc176e7 fix(css): set line-height on article headings to prevent overlap when they wrap
Article headings inherit .entry-content's fixed line-height: 1.4em (computed at
the body font-size), which is smaller than the heading font-size, so headings
that wrap onto two lines overlap. Add an explicit unitless line-height: 1.2
scoped to .entry-content headings so the leading scales with each heading's own
font-size, without affecting Miniflux's own UI headings.

Fixes #4399.
2026-07-04 16:51:19 -07:00
Saleh 0abd9e7145 fix(json): escape plain-text content_text and summary
JSON Feed 1.1 defines content_html as HTML but content_text and summary
as plain text. The adapter stored whichever was present directly in
entry.Content, which is treated as HTML everywhere downstream, so any
markup-like characters in a content_text or summary value (for example
"<tag>") were dropped by the sanitizer.

Escape content_text and summary with html.EscapeString before storing
them, leaving content_html untouched. This mirrors how the Atom 0.3
reader already escapes plain-text constructs.
2026-07-03 18:57:53 -07:00
jvoisin ef24215bde perf(response): pool on-the-fly compression writers
For every dynamic response above the 1 KiB compression threshold the
builder previously did one of:
    brotli.NewWriterV2(b.w, brotli.DefaultCompression)
    gzip.NewWriter(b.w)
    flate.NewWriter(b.w, -1)

Each constructor allocates the encoder's working set from scratch:
brotli's sliding window + hash tables (~3.5 MiB at quality 6), gzip's
CRC32 + deflate state (~800 KiB), flate's hash chains (~800 KiB).
On a busy server that's per-request churn the GC then has to clean up.

All three writer types expose Reset(dst io.Writer), which rebinds the
destination without touching the internal buffers. So put each behind a
sync.Pool and Get/Reset/Put around the existing Write+Close pair. The
constructor signatures didn't change; only Pool.Get + Reset are new.

Note that brotli.NewWriterV2 (kept from before this change) returns
*matchfinder.Writer, not *brotli.Writer, as V2 is the pure-Go encoder
built on top of github.com/andybalholm/brotli/matchfinder, where the
actual Writer type lives. Hence the matchfinder import.

On a local artificial benchmarks of a 130 KiB HTML-like payload consisting of
250 entry-list items, on a single-core it improves performances by around 10%,
and for multicore under GC pressure, ns/op is reduces by ~80% and B/op by ~99%.
2026-07-03 18:51:50 -07:00
Salih Muhammed 56d0b31cb6 fix(template): avoid race when rendering concurrently
Render bound per-request, language-specific functions (t, plural, elapsed)
onto the shared template before executing it. Doing that while other
goroutines execute the same template races on its function map and can
render a response using another request's language.

Clone the template before binding those functions so each request executes
its own copy.

Fixes #4380
2026-07-02 17:20:55 -07:00
Fred 5f710f916d feat(reader): inherit feed language on RSS, RDF, and JSON Feed entries
Entries without their own language now take the feed-level value,
matching the behaviour introduced for Atom. For JSON Feed this is
spec-mandated: an item declares a language only when it differs from
the primary language of the feed. For RSS and RDF, items are part of
the channel's content, and API consumers previously saw an empty
entries.language even when the channel declared one.

The RSS channel now also reads <dc:language>: hybrid feeds commonly
declare the channel language via Dublin Core instead of <language>.
2026-07-02 16:40:52 -07:00
Fred 65cd6cd25d fix(language): reject tags outside the BCP-47 alphabet in Normalize
Feed-declared language values were persisted and rendered with no
charset or length restriction. Normalize now rejects values longer
than 50 bytes or containing characters outside [a-z0-9-], keeping
control characters, NUL bytes (which Postgres rejects, failing the
feed refresh), and oversized values out of the database and the HTML
lang attribute. Invalid values are rejected rather than stripped,
since stripping could assemble a wrong tag ("fr, en" -> "fren").

Lower-casing is ASCII-only and done in the same pass as the charset
check: Unicode case folding maps some non-ASCII characters to ASCII
(e.g. the Kelvin sign U+212A to "k"), laundering input the filter
should reject into apparently valid tags.
2026-07-02 13:17:17 -07:00
Fred 6972be2e85 refactor(reader): move language normalization out of model
NormalizeLanguage lived in the model package but is a parse-time
input-cleaning helper: all of its callers are the feed-format adapters,
nothing in model uses it, and as a free function it never enforced a
model invariant. Parse-time normalization helpers belong under
internal/reader alongside date, sanitizer, and urlcleaner.

Move it to a new internal/reader/language package and rename it to
Normalize so call sites read language.Normalize(...). No behaviour
change.
2026-07-02 11:28:41 -07:00
Fred ab8f7f9eb4 feat(atom): inherit feed-level xml:lang on entries
Per the XML specification, xml:lang applies to the whole subtree it is
declared on, so an Atom entry without its own xml:lang attribute takes
the language declared on the feed element. The parser previously left
such entries with an empty language, and only the web UI compensated
with a template-level fallback; API consumers reading entries.language
saw an empty value even when the feed declared one.

Apply the fallback in the Atom 1.0 and 0.3 adapters so the inherited
value is persisted and exposed everywhere.

Known limitation: an explicit xml:lang="" on an entry, which the spec
defines as undefining the language for that subtree, cannot be
distinguished from an absent attribute with a plain string field and
therefore inherits as well.
2026-07-02 11:15:35 -07:00
Fred 766d298095 fix(atom): restrict language parsing to namespace-qualified xml:lang
The Language fields added for Atom 1.0 and 0.3 feeds and entries used
the struct tag `xml:"lang,attr"`, which encoding/xml matches against a
lang attribute from any namespace, with the last one in document order
winning. A feed carrying e.g. foo:lang="zz" after xml:lang="fr" would
be stored with language "zz".

Qualify the tags with the XML namespace so only xml:lang matches. As a
side effect, a bare non-standard lang attribute is now ignored instead
of being treated as the feed language.

Also document each Language field and add regression tests covering
foreign-namespace and unqualified lang attributes, plus first coverage
of xml:lang parsing for Atom 0.3.
2026-07-02 11:00:51 -07:00
dependabot[bot] 68e2655b77 build(deps): bump library/alpine
Bumps the docker group with 1 update in the /packaging/docker/alpine directory: library/alpine.


Updates `library/alpine` from 3.23 to 3.24

---
updated-dependencies:
- dependency-name: library/alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: docker
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 21:01:43 -07:00
dependabot[bot] fe37c6ba43 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.3` | `7.0.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `6.5.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.36.1` | `4.36.2` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.36.1` | `4.36.2` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.36.1` | `4.36.2` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.2.0` | `7.3.0` |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `9.2.1` | `9.3.0` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` |


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [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/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16)

Updates `github/codeql-action/init` from 4.36.1 to 4.36.2
- [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/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

Updates `github/codeql-action/autobuild` from 4.36.1 to 4.36.2
- [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/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

Updates `github/codeql-action/analyze` from 4.36.1 to 4.36.2
- [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/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

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

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

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

Updates `actions/setup-python` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-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.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: 6.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-07-01 21:01:20 -07:00
dependabot[bot] 0dcefa7c62 build(deps): bump github.com/andybalholm/brotli in the gomod group
Bumps the gomod group with 1 update: [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli).


Updates `github.com/andybalholm/brotli` from 1.2.1 to 1.2.2
- [Commits](https://github.com/andybalholm/brotli/compare/v1.2.1...v1.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 21:00:57 -07:00
Bram Duvigneau 5d56902c7f feat(client): expose feed and entry language on public Go client structs
Mirrors the new `language` JSON field on the server-side Feed and Entry
models so that third-party consumers of the Go API client can read the
value declared by feeds and entries.
2026-07-01 21:00:17 -07:00
Bram Duvigneau 212c3e13d5 feat(rdf): parse feed and item language from RDF/RSS 1.0 dc:language
Extends language parsing to RDF/RSS 1.0 feeds by reading the
Dublin Core dc:language element at both the channel and item level.
Values are normalized the same way as for RSS 2.0 and Atom.
2026-07-01 21:00:17 -07:00
Bram Duvigneau 6dcb815c25 feat(ui): emit lang attribute on rendered article surfaces
Renders lang="..." on the entry title (<h1> in detail view, <h2> in every
list view) and on the entry content <article>. The attribute prefers the
entry-level language and falls back to the feed-level language; if both
are empty, no lang= attribute is emitted (rather than lang="").
2026-07-01 21:00:17 -07:00
Bram Duvigneau d456718c05 feat(reader): parse and persist feed and entry language from RSS, Atom, and JSON Feed
Reads the language declared by each feed and entry at parse time, persists
it on new `feeds.language` and `entries.language` columns, and exposes both
via the existing Feed/Entry JSON marshalling.

Sources:
- RSS feed: <language>
- RSS item: <dc:language>
- Atom 1.0 feed/entry: xml:lang
- Atom 0.3 feed/entry: xml:lang
- JSON Feed feed/item: "language"

Values are normalized at parse time (trim + lower-case + _ -> -) so they
are directly usable as an HTML lang attribute. No strict BCP-47 validation
is performed: many real feeds use loose values, and silently dropping them
yields worse downstream behaviour than passing them through.

The refresh path treats language as feed/entry-declared metadata and always
trusts the latest fetched value.
2026-07-01 21:00:17 -07:00
gudvinr 4aa60ba23d refactor(response): use strict Accept-Encoding parser 2026-06-30 20:44:16 -07:00
gudvinr 7769fa06ef feat(response): almost standard-compliant Accept-Encoding parser 2026-06-30 20:44:16 -07:00
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
211 changed files with 6375 additions and 2084 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Golang
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: stable
check-latest: true
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Mirror to Codeberg
+5 -5
View File
@@ -38,22 +38,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{ matrix.language }}"
+9 -9
View File
@@ -19,13 +19,13 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -40,13 +40,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
@@ -66,13 +66,13 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
id: buildx
with:
install: true
+10 -10
View File
@@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -38,7 +38,7 @@ jobs:
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
@@ -52,21 +52,21 @@ jobs:
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -74,14 +74,14 @@ jobs:
- name: Login to Quay Container Registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build and Push Alpine images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
@@ -90,7 +90,7 @@ jobs:
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,11 +25,11 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
- name: Run gofmt linter
run: gofmt -d -e .
@@ -38,11 +38,11 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.13'
- name: Validate PR commits
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Build RPM Package
@@ -31,7 +31,7 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Build RPM Package
@@ -48,7 +48,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Build RPM Package
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-pr-stale: 60
days-before-pr-close: 14
+5 -5
View File
@@ -17,9 +17,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: stable
- name: Install Postgres client
+71
View File
@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}
// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryIDsContext(ctx, filter)
}
// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
if err != nil {
return nil, err
}
defer body.Close()
var result EntryIDsResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
if filter == nil {
return path
}
params := url.Values{}
if filter.Limit > 0 {
params.Set("limit", strconv.Itoa(filter.Limit))
}
if filter.Offset > 0 {
params.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.Starred != nil {
params.Set("starred", strconv.FormatBool(*filter.Starred))
}
if filter.Status != "" {
params.Set("status", filter.Status)
}
if len(params) == 0 {
return path
}
return path + "?" + params.Encode()
}
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
@@ -981,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
return err
}
// UpdateEntriesStarred updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
}
// UpdateEntriesStarredContext updates the starred state of a list of entries.
func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
@@ -1231,6 +1298,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
values.Add("status", status)
}
for _, tag := range filter.Tags {
values.Add("tags", tag)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
+128
View File
@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
}
}
func TestUpdateEntriesStarred(t *testing.T) {
starred := true
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
expectFromJSON(t, req.Body, &struct {
EntryIDs []int64 `json:"entry_ids"`
Starred *bool `json:"starred"`
}{
EntryIDs: []int64{1, 2},
Starred: &starred,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
expected := &Entry{
ID: 1,
@@ -1286,3 +1307,110 @@ func TestUpdateEnclosure(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
}
func boolPtr(b bool) *bool { return &b }
func TestEntryIDsNoFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 2,
EntryIDs: []int64{1, 2},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithPaginationFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 5,
EntryIDs: []int64{3},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStarredFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 1,
EntryIDs: []int64{42},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithStatusFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 10,
EntryIDs: []int64{7, 8},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntryIDsWithCombinedFilter(t *testing.T) {
expected := &EntryIDsResultSet{
Total: 3,
EntryIDs: []int64{5},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
+31 -2
View File
@@ -74,8 +74,6 @@ type UserModificationRequest struct {
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
@@ -148,12 +146,16 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
Language string `json:"language"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -174,6 +176,14 @@ type Feed struct {
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverEnabled bool `json:"pushover_enabled"`
PushoverPriority int `json:"pushover_priority"`
Icon *FeedIcon `json:"icon"`
}
// FeedCreationRequest represents the request to create a feed.
@@ -187,6 +197,7 @@ type FeedCreationRequest struct {
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
@@ -207,6 +218,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
@@ -222,6 +234,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"`
@@ -258,6 +271,7 @@ type Entry struct {
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
Language string `json:"language"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Enclosures Enclosures `json:"enclosures,omitempty"`
@@ -320,6 +334,7 @@ type Filter struct {
CategoryID int64
FeedID int64
Statuses []string
Tags []string
GloballyVisible bool
}
@@ -329,6 +344,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"`
+14 -16
View File
@@ -7,22 +7,22 @@ go 1.26.0
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-webauthn/webauthn v0.17.3
github.com/andybalholm/brotli v1.2.2
github.com/coreos/go-oidc/v3 v3.20.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/prometheus/client_golang v1.24.0
github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
)
require (
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
)
@@ -35,16 +35,14 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // 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.44.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+32 -42
View File
@@ -1,16 +1,15 @@
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
@@ -19,10 +18,10 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -34,12 +33,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
@@ -50,16 +45,14 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
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.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
@@ -80,18 +73,18 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -106,8 +99,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -128,8 +121,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -139,8 +132,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -150,8 +143,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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=
@@ -159,10 +152,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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)
File diff suppressed because it is too large Load Diff
+85
View File
@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
}
}
func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
// Unauthenticated request should be rejected before param validation.
if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}
func TestParseEntryIDsParamsDefaults(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected default limit 10000, got %d`, limit)
}
if offset != 0 {
t.Fatalf(`Expected default offset 0, got %d`, offset)
}
}
func TestParseEntryIDsParamsCustomValues(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
limit, offset := parseEntryIDsParams(r)
if limit != 500 {
t.Fatalf(`Expected limit 500, got %d`, limit)
}
if offset != 100 {
t.Fatalf(`Expected offset 100, got %d`, offset)
}
}
func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
}
}
func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
limit, _ := parseEntryIDsParams(r)
if limit != 10000 {
t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
}
}
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
scenarios := []struct {
name string
+15 -11
View File
@@ -112,14 +112,19 @@ func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Reque
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
var categories model.Categories
var err error
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
if request.QueryBoolParam(r, "counts", false) {
user, userErr := h.store.UserByID(request.UserID(r))
if userErr != nil {
response.JSONServerError(w, r, userErr)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
} else {
categories, err = h.store.Categories(request.UserID(r))
@@ -163,15 +168,14 @@ func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request)
return
}
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithUserID(userID).
WithCategoryID(categoryID).
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
+2 -14
View File
@@ -22,7 +22,7 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -33,12 +33,6 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, enclosure)
@@ -62,7 +56,7 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -73,12 +67,6 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
response.JSONServerError(w, r, err)
+112 -53
View File
@@ -55,9 +55,9 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithFeedID(feedID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -75,9 +75,9 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithCategoryID(categoryID).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -89,8 +89,8 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID)
h.getEntryFromBuilder(w, r, builder)
}
@@ -161,25 +161,25 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
tags := request.QueryStringParamList(r, "tags")
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)
builder.WithSorting(order, direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder := h.store.NewEntryQueryBuilder(userID).
WithFeedID(feedID).
WithCategoryID(categoryID).
WithStatuses(statuses...).
WithSorting(order, direction).
WithOffset(offset).
WithLimit(limit).
WithTags(tags...).
WithEnclosures()
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
builder = builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
builder = configureFilters(builder, r)
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
@@ -189,26 +189,36 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
entries[i].Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
}
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
if entriesStatusUpdateRequest.Status != "" {
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
}
if entriesStatusUpdateRequest.Starred != nil {
if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
response.JSONServerError(w, r, err)
return
}
}
response.NoContent(w, r)
@@ -236,15 +246,14 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
if !h.store.HasSaveEntry(request.UserID(r)) {
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -285,10 +294,10 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
}
loggedUserID := request.UserID(r)
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -445,10 +454,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -470,9 +478,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
WithFeedID(entry.FeedID).
GetFeed()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -499,57 +507,108 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
if request.HasQueryParam(r, "starred") {
starredValue := request.QueryStringParam(r, "starred", "")
if starredValue != "true" && starredValue != "false" {
response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
return
}
}
if request.HasQueryParam(r, "status") {
statusValue := request.QueryStringParam(r, "status", "")
if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
return
}
}
limit, offset := parseEntryIDsParams(r)
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithSorting("id", "DESC").
WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
WithOffset(offset)
if request.HasQueryParam(r, "starred") {
builder.WithStarred(request.QueryBoolParam(r, "starred", false))
}
if request.HasQueryParam(r, "status") {
builder.WithStatuses(request.QueryStringParam(r, "status", ""))
}
entryIDs, total, err := builder.GetEntryIDsWithCount()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entryIDs == nil {
entryIDs = []int64{}
}
response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
}
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
response.JSONAccepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) *storage.EntryQueryBuilder {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
builder = builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
builder = builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
builder.WithCategoryID(categoryID)
builder = builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
builder = builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
builder = builder.WithSearchQuery(searchQuery)
}
return builder
}
func parseEntryIDsParams(r *http.Request) (limit, offset int) {
limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
if limit <= 0 || limit > model.MaxEntryIDsLimit {
limit = model.MaxEntryIDsLimit
}
offset = request.QueryIntParam(r, "offset", 0)
return limit, offset
}
+8 -9
View File
@@ -76,14 +76,13 @@ func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := h.store.NewBatchBuilder().
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithUserID(userID).
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -116,7 +115,7 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
response.JSONNotFound(w, r)
response.JSONServerError(w, r, err)
return
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request)
return
}
icon, err := h.store.IconByID(iconID)
icon, err := h.store.IconByUserAndIconID(request.UserID(r), iconID)
if err != nil {
response.JSONServerError(w, r, err)
return
+5
View File
@@ -26,6 +26,11 @@ type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryIDsResponse struct {
Total int `json:"total"`
EntryIDs []int64 `json:"entry_ids"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
+11 -11
View File
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Re
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy).
WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(subscriptionDiscoveryRequest.Cookie).
WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password).
IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates).
DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
+21 -4
View File
@@ -22,6 +22,11 @@ func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
@@ -113,7 +118,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
return
}
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -128,7 +139,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
@@ -181,7 +198,7 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
@@ -203,7 +220,7 @@ func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request)
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
response.JSONServerError(w, r, err)
return
}
+4 -4
View File
@@ -25,24 +25,24 @@ func askCredentials() (string, string) {
reader := bufio.NewReader(os.Stdin)
username, err := reader.ReadString('\n')
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
printfAndExit("unable to read username: %w", err)
}
fmt.Print("Enter Password: ")
state, err := term.GetState(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
printfAndExit("unable to get terminal state: %w", err)
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
printfAndExit("unable to restore terminal state: %w", restoreErr)
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
printfAndExit("unable to read password: %w", err)
}
fmt.Print("\n")
+11 -6
View File
@@ -124,7 +124,7 @@ func Parse() {
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
printfAndExit("unable to open log file: %v", err)
}
defer logFileHandler.(*os.File).Close()
}
@@ -143,15 +143,15 @@ func Parse() {
}
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
printfAndExit("unable to generate binary files bundle: %v", err)
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
printfAndExit("unable to generate stylesheets bundle: %v", err)
}
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
printfAndExit("unable to generate javascript bundle: %v", err)
}
db, err := database.NewConnectionPool(
@@ -161,7 +161,7 @@ func Parse() {
config.Opts.DatabaseConnectionLifetime(),
)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
printfAndExit("unable to connect to database: %v", err)
}
defer db.Close()
@@ -231,7 +231,7 @@ func Parse() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
printfAndExit("unable to initialize proxy rotator: %v", err)
}
}
@@ -252,3 +252,8 @@ func printErrorAndExit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func printfAndExit(format string, args ...any) {
err := fmt.Errorf(format, args...)
printErrorAndExit(err)
}
+36 -21
View File
@@ -27,6 +27,9 @@ func startDaemon(store *storage.Storage) {
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
reload := make(chan os.Signal, 1)
signal.Notify(reload, syscall.SIGHUP)
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
@@ -34,8 +37,9 @@ func startDaemon(store *storage.Storage) {
}
var httpServers []*http.Server
var certReloadFn func()
if config.Opts.HasHTTPService() {
httpServers = server.StartWebServer(store, pool)
httpServers, certReloadFn = server.StartWebServer(store, pool)
}
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
@@ -74,29 +78,40 @@ func startDaemon(store *storage.Storage) {
}
}
<-stop
slog.Debug("Shutting down the process")
cancelMetrics()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
select {
case <-stop:
slog.Debug("Shutting down the process")
cancelMetrics()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, server := range httpServers {
if server != nil {
if err := server.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, srv := range httpServers {
if srv != nil {
if err := srv.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", srv.Addr))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Shutting down worker pool...")
pool.Shutdown()
slog.Debug("Worker pool shut down.")
slog.Debug("Process gracefully stopped")
return
case <-reload:
slog.Info("Received SIGHUP, reloading TLS certificates")
if certReloadFn != nil {
certReloadFn()
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Shutting down worker pool...")
pool.Shutdown()
slog.Debug("Worker pool shut down.")
slog.Debug("Process gracefully stopped")
}
+3 -3
View File
@@ -13,17 +13,17 @@ import (
func exportUserFeeds(store *storage.Storage, username string) {
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
printfAndExit("unable to find user: %w", err)
}
if user == nil {
printErrorAndExit(fmt.Errorf("user %q not found", username))
printfAndExit("user %q not found", username)
}
opmlHandler := opml.NewHandler(store)
opmlExport, err := opmlHandler.Export(user.ID)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
printfAndExit("unable to export feeds: %w", err)
}
fmt.Println(opmlExport)
+2 -3
View File
@@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"log/slog"
"net/http"
"time"
@@ -22,12 +21,12 @@ func doHealthCheck(healthCheckEndpoint string) {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
printfAndExit(`health check failure: %v`, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
printfAndExit(`health check failed with status code %d`, resp.StatusCode)
}
slog.Debug(`Health check is passing`)
+8 -9
View File
@@ -20,14 +20,13 @@ func refreshFeeds(store *storage.Storage) {
startTime := time.Now()
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(config.Opts.BatchSize())
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
jobs, err := store.NewBatchBuilder().
WithBatchSize(config.Opts.BatchSize()).
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
@@ -36,7 +35,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
var jobQueue = make(chan model.Job, nbJobs)
jobQueue := make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
+8 -7
View File
@@ -33,14 +33,15 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
jobs, err := store.NewBatchBuilder().
WithBatchSize(batchSize).
WithErrorLimit(errorLimit).
WithoutDisabledFeeds().
WithNextCheckExpired().
WithLimitPerHost(limitPerHost).
FetchJobs()
if jobs, err := batchBuilder.FetchJobs(); err != nil {
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
+14 -2
View File
@@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"errors"
"maps"
"net"
"net/url"
@@ -383,7 +384,11 @@ func NewConfigOptions() *configOptions {
rawValue: "image",
valueType: stringListType,
validator: func(rawValue string) error {
return validateListChoices(strings.Split(rawValue, ","), []string{"image", "video", "audio"})
resourceTypes := parseStringListValue(rawValue, nil)
if len(resourceTypes) == 0 {
return errors.New("at least one resource type is required")
}
return validateListChoices(resourceTypes, []string{"image", "video", "audio"})
},
},
"METRICS_ALLOWED_NETWORKS": {
@@ -528,6 +533,9 @@ func NewConfigOptions() *configOptions {
parsedIntValue: 1,
rawValue: "1",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": {
parsedDuration: 24 * time.Hour,
@@ -566,7 +574,11 @@ func NewConfigOptions() *configOptions {
rawValue: "",
valueType: stringListType,
validator: func(rawValue string) error {
for ip := range strings.SplitSeq(rawValue, ",") {
networks := parseStringListValue(rawValue, nil)
if len(networks) == 0 {
return errors.New("at least one CIDR notation network is required")
}
for _, ip := range networks {
if _, _, err := net.ParseCIDR(ip); err != nil {
return err
}
+45
View File
@@ -1149,6 +1149,14 @@ func TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {
if configParser.options.SchedulerEntryFrequencyFactor() != 2 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=0"}); err == nil {
t.Fatalf("Expected an error for SCHEDULER_ENTRY_FREQUENCY_FACTOR=0")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=-1"}); err == nil {
t.Fatalf("Expected an error for SCHEDULER_ENTRY_FREQUENCY_FACTOR=-1")
}
}
func TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {
@@ -1500,9 +1508,26 @@ func TestMediaProxyResourceTypesOptionParsing(t *testing.T) {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image, video"}); err != nil {
t.Fatalf("Unexpected error for value with spaces: %v", err)
}
resourceTypes = configParser.options.MediaProxyResourceTypes()
if len(resourceTypes) != 2 || resourceTypes[0] != "image" || resourceTypes[1] != "video" {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,video,"}); err != nil {
t.Fatalf("Unexpected error for value with trailing comma: %v", err)
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video"}); err == nil {
t.Fatal("Expected error due to invalid resource type")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=,"}); err == nil {
t.Fatal("Expected error for value without any resource type")
}
}
func TestMetricsAllowedNetworksOptionParsing(t *testing.T) {
@@ -1644,10 +1669,30 @@ func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
}
// Test value with spaces and trailing comma
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=192.168.0.0/16, 10.0.0.0/8,"}); err != nil {
t.Fatalf("Unexpected error for value with spaces: %v", err)
}
allowedNetworks = configParser.options.TrustedReverseProxyNetworks()
if len(allowedNetworks) != 2 {
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
}
if !slices.Contains(allowedNetworks, "192.168.0.0/16") {
t.Errorf("Expected 192.168.0.0/16 in allowed networks")
}
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
}
// Test invalid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
}
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=,"}); err == nil {
t.Fatal("Expected error for value without any network")
}
}
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
+49 -2
View File
@@ -353,7 +353,11 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
// This migration originally used md5(url), but it was changed to
// sha256 because PostgreSQL 18 disables MD5 in FIPS mode, which made
// fresh installs fail while replaying this migration. Existing
// installs that already ran it are migrated later on.
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`
_, err = tx.Exec(sql)
return err
},
@@ -724,7 +728,12 @@ var migrations = [...]func(tx *sql.Tx) error{
}
// Create unique index
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
//
// This originally used md5(url), but it was changed to sha256 because
// PostgreSQL 18 disables MD5 in FIPS mode, which made fresh installs
// fail while replaying this migration. Existing installs that already
// ran it are migrated later on.
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`)
if err != nil {
return err
}
@@ -1470,10 +1479,15 @@ var migrations = [...]func(tx *sql.Tx) error{
CREATE INDEX entry_tombstones_deleted_at_idx
ON entry_tombstones (deleted_at);
-- Some databases contain orphaned entries whose feed is gone,
-- e.g. restored from a dump without foreign key enforcement.
-- Skip them: their tombstones would be useless anyway, and the
-- rows are deleted just below.
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
SELECT feed_id, hash, changed_at
FROM entries
WHERE status = 'removed' AND hash <> ''
AND feed_id IN (SELECT id FROM feeds)
ON CONFLICT (feed_id, hash) DO NOTHING;
DELETE FROM entries WHERE status = 'removed';
@@ -1510,4 +1524,37 @@ var migrations = [...]func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
// entries_feed_idx is redundant: the unique constraint
// entries_feed_id_hash_key(feed_id, hash) and the explicit
// entries_feed_id_status_hash_idx(feed_id, status, hash) both
// cover feed_id-leading lookups, including FK cascade deletes.
//
// entries_user_status_idx is redundant: five three-column indexes
// share the same (user_id, status) prefix and serve every query
// that the two-column index could.
_, err = tx.Exec(`
DROP INDEX IF EXISTS entries_feed_idx;
DROP INDEX IF EXISTS entries_user_status_idx;
`)
return err
},
func(tx *sql.Tx) (err error) {
// PostgreSQL 18 disables MD5 when running in FIPS mode, which makes
// the unique index on enclosures relying on md5(url) unusable.
// Replace it with a SHA-256 based expression index.
_, err = tx.Exec(`
DROP INDEX IF EXISTS enclosures_user_entry_url_unique_idx;
CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx
ON enclosures (user_id, entry_id, encode(sha256(url::bytea), 'hex'));
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN language text not null default '';
ALTER TABLE entries ADD COLUMN language text not null default '';
`)
return err
},
}
+19 -21
View File
@@ -238,8 +238,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithLimit(50)
builder := h.store.NewEntryQueryBuilder(userID).
WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
@@ -249,8 +249,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
builder = builder.AfterEntryID(sinceID)
builder = builder.WithSorting("id", "ASC")
}
case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0)
@@ -258,14 +258,14 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC")
builder = builder.WithSorting("id", "DESC")
} else if maxID > 0 {
slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC")
builder = builder.BeforeEntryID(maxID)
builder = builder.WithSorting("id", "DESC")
}
case request.HasQueryParam(r, "with_ids"):
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
@@ -278,7 +278,7 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
itemIDs = append(itemIDs, itemID)
}
builder.WithEntryIDs(itemIDs)
builder = builder.WithEntryIDs(itemIDs...)
}
default:
slog.Debug("[Fever] Fetching oldest items",
@@ -292,8 +292,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
return
}
builder = h.store.NewEntryQueryBuilder(userID)
result.Total, err = builder.CountEntries()
result.Total, err = h.store.NewEntryQueryBuilder(userID).
CountEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -342,9 +342,9 @@ func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
rawEntryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStatuses(model.EntryStatusUnread).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -375,10 +375,9 @@ func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request)
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true)
entryIDs, err := builder.GetEntryIDs()
entryIDs, err := h.store.NewEntryQueryBuilder(userID).
WithStarred(true).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -410,10 +409,9 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
entry, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(entryID).
GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
+59 -51
View File
@@ -236,20 +236,19 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
slog.Any("tags", tags),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryIDs(itemIDs)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEntryIDs(itemIDs...).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
}
n := 0
readEntryIDs := make([]int64, 0)
unreadEntryIDs := make([]int64, 0)
starredEntryIDs := make([]int64, 0)
unstarredEntryIDs := make([]int64, 0)
var readEntryIDs []int64
var unreadEntryIDs []int64
var starredEntryIDs []int64
var unstarredEntryIDs []int64
for _, entry := range entries {
if read, exists := tags[ReadStream]; exists {
if read && entry.Status == model.EntryStatusUnread {
@@ -343,9 +342,10 @@ func (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request)
return
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder := fetcher.NewRequestBuilder().
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithUserAgent("", config.Opts.HTTPClientUserAgent())
var rssBridgeURL string
var rssBridgeToken string
@@ -649,12 +649,11 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
slog.Any("item_ids", itemIDs),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEnclosures()
builder.WithEntryIDs(itemIDs)
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
entries, err := builder.GetEntries()
entries, err := h.store.NewEntryQueryBuilder(userID).
WithEnclosures().
WithEntryIDs(itemIDs...).
WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection).
GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
@@ -1012,11 +1011,15 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
slog.String("user_agent", r.UserAgent()),
)
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
builder.WithStatus(model.EntryStatusUnread)
builder = builder.WithStatuses(model.EntryStatusUnread)
default:
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
slog.String("handler", "handleReadingListStreamHandler"),
@@ -1027,14 +1030,12 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
}
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1046,36 +1047,42 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
}
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithStarred(true)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStarred(true).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithStatus(model.EntryStatusRead)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithStatuses(model.EntryStatusRead).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
@@ -1083,6 +1090,7 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
@@ -1091,7 +1099,7 @@ func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestMod
if err != nil {
return nil, 0, err
}
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
itemRefs := make([]itemRef, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
formattedID := strconv.FormatInt(entryID, 10)
itemRefs = append(itemRefs, itemRef{ID: formattedID})
@@ -1115,32 +1123,32 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
return
}
builder := h.store.NewEntryQueryBuilder(rm.UserID)
builder.WithFeedID(feedID)
builder.WithLimit(rm.Count)
builder.WithOffset(rm.Offset)
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
builder := h.store.NewEntryQueryBuilder(rm.UserID).
WithFeedID(feedID).
WithLimit(rm.Count).
WithOffset(rm.Offset).
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
if rm.StartTime > 0 {
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
}
if rm.StopTime > 0 {
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
}
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream {
builder = builder.WithoutStatus(model.EntryStatusRead)
}
}
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
}
+6 -6
View File
@@ -120,10 +120,10 @@ type contentItemOrigin struct {
}
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
builder := response.NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithBodyAsString("Unauthorized")
builder.Write()
response.NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("X-Reader-Google-Bad-Token", "true").
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithBodyAsString("Unauthorized").
Write()
}
+97
View File
@@ -4,16 +4,23 @@
package client // import "miniflux.app/v2/internal/http/client"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultRequestTimeout = 10 * time.Second
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
@@ -68,3 +75,93 @@ func NewClientWithOptions(opts Options) *http.Client {
Transport: transport,
}
}
// requestBuilder builds and executes HTTP requests with the builder pattern.
type requestBuilder struct {
err error
endpoint string
method string
body io.Reader
headers http.Header
}
// NewRequestBuilder creates a new request builder for the given endpoint.
func NewRequestBuilder(endpoint string) *requestBuilder {
return &requestBuilder{
endpoint: endpoint,
method: http.MethodGet,
headers: make(http.Header),
}
}
// WithMethod sets the HTTP method.
func (r *requestBuilder) WithMethod(method string) *requestBuilder {
r.method = method
return r
}
// WithHeader sets a header value.
func (r *requestBuilder) WithHeader(key, value string) *requestBuilder {
r.headers.Set(key, value)
return r
}
// WithJSON marshals payload as JSON, sets the body and Content-Type.
func (r *requestBuilder) WithJSON(payload any) *requestBuilder {
requestBody, err := json.Marshal(payload)
if err != nil {
r.err = fmt.Errorf("unable to encode request body: %w", err)
return r
}
return r.WithJSONBody(requestBody)
}
// WithJSONBody sets an already-marshaled JSON body and the Content-Type.
// It is useful when the caller needs the encoded payload for another
// purpose (e.g. computing a signature) to avoid marshaling it twice.
func (r *requestBuilder) WithJSONBody(body []byte) *requestBuilder {
r.body = bytes.NewReader(body)
r.headers.Set("Content-Type", "application/json")
return r
}
// Do builds and executes the request.
//
// Private networks are blocked unless explicitly allowed through the
// INTEGRATION_ALLOW_PRIVATE_NETWORKS option.
func (r *requestBuilder) Do() (*http.Response, error) {
if r.err != nil {
return nil, r.err
}
// The request is assembled lazily here rather than being stored as a
// prebuilt *http.Request in the builder: http.NewRequest inspects the
// body's concrete type (e.g. *bytes.Reader) to populate ContentLength and
// GetBody. Constructing it only once the body is known yields a correct
// Content-Length header and lets the client replay the body on redirects.
req, err := http.NewRequest(r.method, r.endpoint, r.body)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
for key, values := range r.headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
clientOptions := Options{
Timeout: defaultRequestTimeout,
BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks(),
}
response, err := NewClientWithOptions(clientOptions).Do(req)
if err != nil {
return nil, fmt.Errorf("unable to send request: %w", err)
}
return response, nil
}
+74
View File
@@ -5,11 +5,15 @@ package client
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/version"
)
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
@@ -111,3 +115,73 @@ func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
func TestRequestBuilderWithJSON(t *testing.T) {
configureIntegrationAllowPrivateNetworksOption(t)
var gotMethod, gotContentType, gotUserAgent, gotAuth, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotContentType = r.Header.Get("Content-Type")
gotUserAgent = r.Header.Get("User-Agent")
gotAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
response, err := NewRequestBuilder(server.URL).
WithMethod(http.MethodPost).
WithHeader("Authorization", "Bearer secret").
WithJSON(map[string]string{"hello": "world"}).
Do()
if err != nil {
t.Fatalf("request execution failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, response.StatusCode)
}
if gotMethod != http.MethodPost {
t.Errorf("expected method POST, got %s", gotMethod)
}
if gotContentType != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", gotContentType)
}
if want := "Miniflux/" + version.Version; gotUserAgent != want {
t.Errorf("expected User-Agent %q, got %q", want, gotUserAgent)
}
if gotAuth != "Bearer secret" {
t.Errorf("expected Authorization %q, got %q", "Bearer secret", gotAuth)
}
if gotBody != `{"hello":"world"}` {
t.Errorf("expected body %q, got %q", `{"hello":"world"}`, gotBody)
}
}
func TestRequestBuilderWithInvalidEndpoint(t *testing.T) {
_, err := NewRequestBuilder("://invalid").WithMethod(http.MethodPost).WithJSON(nil).Do()
if err == nil {
t.Fatal("expected an error for an invalid endpoint, got nil")
}
}
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
t.Helper()
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
configParser := config.NewConfigParser()
parsedOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unable to configure test options: %v", err)
}
previousOptions := config.Opts
config.Opts = parsedOptions
t.Cleanup(func() {
config.Opts = previousOptions
})
}
+66 -26
View File
@@ -6,31 +6,58 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"maps"
"mime"
"net/http"
"strings"
"sync"
"time"
"github.com/andybalholm/brotli"
"github.com/andybalholm/brotli/matchfinder"
)
const compressionThreshold = 1024
// Compression writers are pooled so each request reuses their internal
// state (brotli sliding window + hash tables, flate dictionary, etc.)
// instead of allocating it from scratch. Reset(dst) rebinds the
// destination without re-allocating the buffers.
var (
brotliWriterPool = sync.Pool{
New: func() any {
return brotli.NewWriterV2(io.Discard, brotli.DefaultCompression)
},
}
gzipWriterPool = sync.Pool{
New: func() any {
return gzip.NewWriter(io.Discard)
},
}
flateWriterPool = sync.Pool{
New: func() any {
w, _ := flate.NewWriter(io.Discard, flate.DefaultCompression)
return w
},
}
)
// Builder generates HTTP responses.
type Builder struct {
w http.ResponseWriter
r *http.Request
statusCode int
headers map[string]string
headers http.Header
enableCompression bool
body any
}
// NewBuilder creates a new response builder.
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
}
// WithStatus uses the given status code to build the response.
@@ -41,7 +68,7 @@ func (b *Builder) WithStatus(statusCode int) *Builder {
// WithHeader adds the given HTTP header to the response.
func (b *Builder) WithHeader(key, value string) *Builder {
b.headers[key] = value
b.headers.Set(key, value)
return b
}
@@ -65,13 +92,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = formatContentDisposition("attachment", filename)
b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
return b
}
// WithInline suggests an inline filename for the current response.
func (b *Builder) WithInline(filename string) *Builder {
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
return b
}
@@ -84,9 +111,11 @@ func (b *Builder) WithoutCompression() *Builder {
// WithCaching adds caching headers to the response.
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
etag = normalizeETag(etag)
b.headers["ETag"] = etag
b.headers["Cache-Control"] = "public, immutable"
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
b.headers.Set("ETag", etag)
// max-age is required for the "immutable" directive to take effect: without
// it, browsers still revalidate content-hashed assets on every reload.
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
b.statusCode = http.StatusNotModified
@@ -120,45 +149,56 @@ 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)
}
// values should be in sync with [Builder.compress] switch/case.
var acceptEncoding = AcceptEncoding("br", "gzip", "deflate")
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
b.headers["Vary"] = "Accept-Encoding"
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.headers.Set("Vary", "Accept-Encoding")
encoding := acceptEncoding.Parse(b.r.Header.Get("Accept-Encoding"))
switch encoding {
case "br":
b.headers.Set("Content-Encoding", "br")
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
brotliWriter := brotliWriterPool.Get().(*matchfinder.Writer)
brotliWriter.Reset(b.w)
brotliWriter.Write(data)
brotliWriter.Close()
brotliWriter.Reset(io.Discard)
brotliWriterPool.Put(brotliWriter)
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
case "gzip":
b.headers.Set("Content-Encoding", "gzip")
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
gzipWriter := gzipWriterPool.Get().(*gzip.Writer)
gzipWriter.Reset(b.w)
gzipWriter.Write(data)
gzipWriter.Close()
gzipWriter.Reset(io.Discard)
gzipWriterPool.Put(gzipWriter)
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
case "deflate":
b.headers.Set("Content-Encoding", "deflate")
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
flateWriter := flateWriterPool.Get().(*flate.Writer)
flateWriter.Reset(b.w)
flateWriter.Write(data)
flateWriter.Close()
flateWriter.Reset(io.Discard)
flateWriterPool.Put(flateWriter)
return
}
}
+3 -3
View File
@@ -240,7 +240,7 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedHeader := "public, immutable"
expectedHeader := "public, max-age=60, immutable"
actualHeader := resp.Header.Get("Cache-Control")
if actualHeader != expectedHeader {
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
@@ -297,7 +297,7 @@ func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
}
if resp.Header.Get("Cache-Control") != "public, immutable" {
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
}
@@ -358,7 +358,7 @@ func TestIfNoneMatch(t *testing.T) {
func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
r.Header.Set("Accept-Encoding", "br, gzip, deflate")
if err != nil {
t.Fatal(err)
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response
import (
"slices"
"strconv"
"strings"
)
type acceptEncodingParser struct {
// accepted contains all encoding that particular parser instance advertises.
accepted []string
}
// AcceptEncoding creates parser instance for "Accept-Encoding" header values.
// It accepts list of encodings recognized by user of this parser instance.
func AcceptEncoding(accepted ...string) *acceptEncodingParser {
return &acceptEncodingParser{accepted: accepted}
}
// Parse parses input string according to [HTTP Semantics]
// and returns first encoding that can be understood by us.
//
// Currently this function ignores set weights other than q=0.
// Encodings with q=0 will not be considered.
//
// If string is empty or no encoding was accepted function returns "identity".
//
// For "identity;q=0" and "*;q=0" function returns an empty string. In that case,
// if no other encoding was accepted, 406 Not Acceptable should be returned.
//
// [HTTP Semantics]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding.
func (p *acceptEncodingParser) Parse(acceptEncoding string) string {
accepted := "identity"
for enc := range strings.SplitSeq(acceptEncoding, ",") {
enc = strings.TrimSpace(enc)
if qi := strings.IndexByte(enc, ';'); qi > -1 {
qstr := strings.TrimPrefix(enc[qi:], ";")
qstr = strings.TrimSpace(qstr)
qstr = strings.TrimPrefix(qstr, "q=")
q, err := strconv.ParseFloat(qstr, 64)
if err != nil {
continue // Ignore weird float values.
}
enc = strings.TrimSpace(enc[:qi])
if q == 0 && slices.Contains([]string{"identity", "*"}, enc) {
accepted = "" // Explicitly disabled, so can't be used as fallback.
continue
}
if q == 0 {
continue // Skipping unwanted.
}
}
if !slices.Contains(p.accepted, enc) {
continue // Skipping unsupported.
}
accepted = enc
break
}
return accepted
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package response
import (
"testing"
)
func TestAcceptEncoding(t *testing.T) {
t.Parallel()
acceptable := []string{
"br", "gzip", "deflate",
}
tests := []struct {
name string
acceptEncoding string
want string
}{
{
name: "Empty input",
acceptEncoding: "",
want: "identity",
},
{
name: "q=0 and identity",
acceptEncoding: "identity;q=0",
want: "",
},
{
name: "q=0 and *",
acceptEncoding: "*;q=0",
want: "",
},
{
name: "gzip",
acceptEncoding: "gzip",
want: "gzip",
},
{
name: "gzip and br",
acceptEncoding: "gzip,br",
want: "gzip",
},
{
name: "br and gzip",
acceptEncoding: "br,gzip,deflate",
want: "br",
},
{
name: "unsupported encoding",
acceptEncoding: "unknown",
want: "identity",
},
{
name: "empty encoding",
acceptEncoding: ",",
want: "identity",
},
{
name: "multiple encodings and q=0",
acceptEncoding: "gzip;q=0,br;q=0",
want: "identity",
},
{
// We want br here but weights are not supported.
name: "multiple encodings and q values",
acceptEncoding: "gzip;q=0.5,br;q=0.8",
want: "gzip",
},
{
name: "multiple encodings and wildcard",
acceptEncoding: "*;q=0,gzip,br",
want: "gzip",
},
{
name: "multiple encodings and wildcard and q=0",
acceptEncoding: "*;q=0,gzip,br;q=0",
want: "gzip",
},
{
// We want br here but weights are not supported.
name: "multiple encodings and wildcard and q values",
acceptEncoding: "*;q=0.5,gzip;q=0.8,br",
want: "gzip",
},
{
name: "multiple encodings and wildcard and q values and q=0",
acceptEncoding: "*;q=0.5,gzip;q=0.8,br;q=0",
want: "gzip",
},
{
name: "invalid q value",
acceptEncoding: "gzip;q=abc,deflate",
want: "deflate",
},
{
name: "wrong spaces placing around q value",
acceptEncoding: "gzip;q= 0.5, deflate;q=0.8",
want: "deflate",
},
{
name: "correct spaces placing around q value",
acceptEncoding: "gzip ; q=0.5, deflate;q=0.8",
want: "gzip",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// Instantiate parser for each test to make sure it doesn't return cached values.
parser := AcceptEncoding(acceptable...)
got := parser.Parse(test.acceptEncoding)
if got != test.want {
t.Errorf("Parse(%q) = %q, want %q", test.acceptEncoding, got, test.want)
}
})
}
}
func BenchmarkAcceptEncoding(b *testing.B) {
encoding := "identity;q=0,gzip,whatever"
expected := "gzip"
parser := AcceptEncoding("br", "gzip", "deflate")
for b.Loop() {
got := parser.Parse(encoding)
if got != expected {
b.Errorf("Parse(%q) = %q, want %q", encoding, got, expected)
}
}
}
+40 -38
View File
@@ -15,15 +15,17 @@ import (
// HTML creates a new HTML response with a 200 status code.
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder := NewBuilder(w, r).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
switch v := any(body).(type) {
case []byte:
builder.WithBodyAsBytes(v)
builder = builder.WithBodyAsBytes(v)
case string:
builder.WithBodyAsString(v)
builder = builder.WithBodyAsString(v)
}
builder.Write()
}
@@ -42,13 +44,13 @@ func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLBadRequest sends a bad request error to the client.
@@ -66,13 +68,13 @@ func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString(html.EscapeString(err.Error()))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
WithHeader("Content-Type", "text/plain; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString(html.EscapeString(err.Error())).
Write()
}
// HTMLForbidden sends a forbidden error to the client.
@@ -89,12 +91,12 @@ func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Access Forbidden")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Access Forbidden").
Write()
}
// HTMLNotFound sends a page not found error to the client.
@@ -111,12 +113,12 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBodyAsString("Page Not Found")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithBodyAsString("Page Not Found").
Write()
}
// HTMLRedirect redirects the user to a relative path or an absolute http(s) URL.
@@ -142,11 +144,11 @@ func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, co
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithHeader("Content-Range", contentRange)
builder.WithBodyAsString("Range Not Satisfiable")
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusRequestedRangeNotSatisfiable).
WithHeader("Content-Type", "text/html; charset=utf-8").
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
WithHeader("Content-Range", contentRange).
WithBodyAsString("Range Not Satisfiable").
Write()
}
+3
View File
@@ -224,6 +224,9 @@ func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
"file:///etc/passwd",
"mailto:victim@example.org",
"//evil.example.org/path",
`/\evil.example.org/path`,
`\evil.example.org\path`,
`/foo\bar`,
"ftp://example.org/file",
"",
}
+38 -38
View File
@@ -22,10 +22,10 @@ func JSON(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONCreated sends a created response to the client.
@@ -36,19 +36,19 @@ func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
return
}
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusCreated)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(responseBody)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusCreated).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(responseBody).
Write()
}
// JSONAccepted sends an accepted response to the client.
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusAccepted)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusAccepted).
WithHeader("Content-Type", jsonContentTypeHeader).
Write()
}
// JSONServerError sends an internal error to the client.
@@ -66,11 +66,11 @@ func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusInternalServerError).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONBadRequest sends a bad request error to the client.
@@ -88,11 +88,11 @@ func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(err))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusBadRequest).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(err)).
Write()
}
// JSONUnauthorized sends a not authorized error to the client.
@@ -109,11 +109,11 @@ func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusUnauthorized).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access unauthorized"))).
Write()
}
// JSONForbidden sends a forbidden error to the client.
@@ -130,11 +130,11 @@ func JSONForbidden(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusForbidden)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusForbidden).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("access forbidden"))).
Write()
}
// JSONNotFound sends a page not found error to the client.
@@ -151,11 +151,11 @@ func JSONNotFound(w http.ResponseWriter, r *http.Request) {
),
)
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNotFound)
builder.WithHeader("Content-Type", jsonContentTypeHeader)
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNotFound).
WithHeader("Content-Type", jsonContentTypeHeader).
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
Write()
}
func generateJSONError(err error) []byte {
+3 -3
View File
@@ -17,7 +17,7 @@ const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-actio
// NoContent sends a no content response to the client.
func NoContent(w http.ResponseWriter, r *http.Request) {
builder := NewBuilder(w, r)
builder.WithStatus(http.StatusNoContent)
builder.Write()
NewBuilder(w, r).
WithStatus(http.StatusNoContent).
Write()
}
+4 -4
View File
@@ -7,8 +7,8 @@ import "net/http"
// Text writes a standard text response with a status 200 OK.
func Text(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", `text/plain; charset=utf-8`).
WithBodyAsString(body).
Write()
}
+9 -9
View File
@@ -7,17 +7,17 @@ import "net/http"
// XML writes a standard XML response with a status 200 OK.
func XML(w http.ResponseWriter, r *http.Request, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithBodyAsString(body).
Write()
}
// XMLAttachment forces the XML document to be downloaded by the web browser.
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
builder := NewBuilder(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
builder.WithBodyAsString(body)
builder.Write()
NewBuilder(w, r).
WithHeader("Content-Type", "text/xml; charset=utf-8").
WithAttachment(filename).
WithBodyAsString(body).
Write()
}
+6 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -54,7 +55,11 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
return false
}
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
// Both checks have to be run to avoid leaking informations
// about the username and the password.
usernameCorrect := crypto.ConstantTimeCmp(username, config.Opts.MetricsUsername())
passwordCorrect := crypto.ConstantTimeCmp(password, config.Opts.MetricsPassword())
if !usernameCorrect || !passwordCorrect {
slog.Warn("Metrics endpoint accessed with invalid username or password",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
+54 -19
View File
@@ -21,7 +21,7 @@ import (
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
func StartWebServer(store *storage.Storage, pool *worker.Pool) ([]*http.Server, func()) {
var servers []*http.Server
autocertTLSConfig, challengeServer := setupAutocert(store)
@@ -39,6 +39,26 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
config.Opts.SetHTTPSValue(true)
}
// Create a single certificate loader shared by all TLS servers
// that use the same cert/key pair.
var certLoader *certificateLoader
if certFile != "" && keyFile != "" {
hasTLSTarget := false
for _, t := range targets {
if t.mode == modeTLS || t.mode == modeUnixSocketTLS {
hasTLSTarget = true
break
}
}
if hasTLSTarget {
var err error
certLoader, err = newCertificateLoader(certFile, keyFile)
if err != nil {
printErrorAndExit("Unable to load TLS certificate from %s / %s: %v", certFile, keyFile, err)
}
}
}
for _, t := range targets {
srv := &http.Server{
Addr: t.address,
@@ -55,11 +75,11 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
case modeUnixSocket:
startUnixSocketServer(srv, t.address)
case modeUnixSocketTLS:
startUnixSocketTLSServer(srv, t.address, t.certFile, t.keyFile)
startUnixSocketTLSServer(srv, t.address, certLoader)
case modeAutocertTLS:
startAutoCertTLSServer(srv, autocertTLSConfig)
case modeTLS:
startTLSServer(srv, t.certFile, t.keyFile)
startTLSServer(srv, certLoader)
default:
startHTTPServer(srv)
}
@@ -67,7 +87,11 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
servers = append(servers, srv)
}
return servers
certReloadFn := func() {}
if certLoader != nil {
certReloadFn = certLoader.Reload
}
return servers, certReloadFn
}
type listenerMode int
@@ -82,10 +106,8 @@ const (
)
type listenTarget struct {
address string
mode listenerMode
certFile string
keyFile string
address string
mode listenerMode
}
func determineListenTargets(addresses []string, certDomain, certFile, keyFile string) []listenTarget {
@@ -111,13 +133,13 @@ func determineListenTargets(addresses []string, certDomain, certFile, keyFile st
switch {
case isUnix && hasCertFiles:
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS, certFile: certFile, keyFile: keyFile})
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS})
case isUnix:
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocket})
case hasAutocert && (addr == ":https" || (i == 0 && strings.Contains(addr, ":"))):
targets = append(targets, listenTarget{address: addr, mode: modeAutocertTLS})
case hasCertFiles:
targets = append(targets, listenTarget{address: addr, mode: modeTLS, certFile: certFile, keyFile: keyFile})
targets = append(targets, listenTarget{address: addr, mode: modeTLS})
default:
targets = append(targets, listenTarget{address: addr, mode: modeHTTP})
}
@@ -195,16 +217,20 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
}()
}
func startUnixSocketTLSServer(server *http.Server, socketFile, certFile, keyFile string) {
func startUnixSocketTLSServer(server *http.Server, socketFile string, certLoader *certificateLoader) {
server.TLSConfig = &tls.Config{
GetCertificate: certLoader.getCertificate,
// NextProtos intentionally nil — ServeTLS auto-configures it
// based on the http2server GODEBUG setting.
}
listener := createUnixSocketListener(socketFile)
go func() {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
if err := server.ServeTLS(listener, "", ""); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
}()
@@ -219,7 +245,7 @@ func createUnixSocketListener(socketFile string) net.Listener {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
if err := os.Chmod(socketFile, 0666); err != nil {
if err := os.Chmod(socketFile, 0660); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
@@ -243,14 +269,23 @@ func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
func startTLSServer(server *http.Server, certLoader *certificateLoader) {
server.TLSConfig = &tls.Config{
GetCertificate: certLoader.getCertificate,
// NextProtos intentionally nil — ServeTLS auto-configures it
// based on the http2server GODEBUG setting.
}
listener, err := net.Listen("tcp", server.Addr)
if err != nil {
printErrorAndExit("TLS server failed to listen on %s: %v", server.Addr, err)
}
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
if err := server.ServeTLS(listener, "", ""); err != http.ErrServerClosed {
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
+37 -4
View File
@@ -4,6 +4,8 @@
package server
import (
"os"
"runtime"
"testing"
)
@@ -37,7 +39,7 @@ func TestDetermineListenTargets(t *testing.T) {
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: ":443", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
{address: ":443", mode: modeTLS},
},
},
{
@@ -94,7 +96,7 @@ func TestDetermineListenTargets(t *testing.T) {
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS},
},
},
{
@@ -103,8 +105,8 @@ func TestDetermineListenTargets(t *testing.T) {
certFile: "/path/to/cert.pem",
keyFile: "/path/to/key.pem",
expected: []listenTarget{
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
{address: ":8080", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS},
{address: ":8080", mode: modeTLS},
},
},
{
@@ -187,3 +189,34 @@ func TestAnyTLS(t *testing.T) {
})
}
}
func TestCreateUnixSocketListenerPermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix sockets are not supported on Windows")
}
tempFile, err := os.CreateTemp("/tmp", "miniflux-*.sock")
if err != nil {
t.Fatalf("Unable to allocate Unix socket path: %v", err)
}
socketFile := tempFile.Name()
if err := tempFile.Close(); err != nil {
t.Fatalf("Unable to close temporary file: %v", err)
}
if err := os.Remove(socketFile); err != nil {
t.Fatalf("Unable to prepare Unix socket path: %v", err)
}
t.Cleanup(func() { os.Remove(socketFile) })
listener := createUnixSocketListener(socketFile)
t.Cleanup(func() { listener.Close() })
fileInfo, err := os.Stat(socketFile)
if err != nil {
t.Fatalf("Unable to stat Unix socket: %v", err)
}
if got, want := fileInfo.Mode().Perm(), os.FileMode(0660); got != want {
t.Errorf("Unix socket permissions = %04o, want %04o", got, want)
}
}
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"crypto/tls"
"log/slog"
"path/filepath"
"sync"
)
// certificateLoader loads and caches a TLS certificate from disk, and
// provides a reload method that can be triggered on SIGHUP.
type certificateLoader struct {
mu sync.RWMutex
cert *tls.Certificate
certFile string
keyFile string
}
func newCertificateLoader(certFile, keyFile string) (*certificateLoader, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
loader := &certificateLoader{
cert: &cert,
certFile: filepath.Clean(certFile),
keyFile: filepath.Clean(keyFile),
}
slog.Info("TLS certificate loaded",
slog.String("cert_file", loader.certFile),
slog.String("key_file", loader.keyFile),
)
return loader, nil
}
// getCertificate returns the currently cached TLS certificate. It satisfies
// the tls.Config.GetCertificate callback and is called by the TLS layer on
// every handshake.
func (cl *certificateLoader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cl.mu.RLock()
defer cl.mu.RUnlock()
return cl.cert, nil
}
// Reload loads the certificate and key from disk and replaces the cached
// copy. If loading fails, the existing certificate is kept and the error
// is logged.
func (cl *certificateLoader) Reload() {
cert, err := tls.LoadX509KeyPair(cl.certFile, cl.keyFile)
if err != nil {
slog.Error("Unable to reload TLS certificate",
slog.String("cert_file", cl.certFile),
slog.String("key_file", cl.keyFile),
slog.Any("error", err),
)
return
}
cl.mu.Lock()
cl.cert = &cert
cl.mu.Unlock()
slog.Info("TLS certificate reloaded successfully",
slog.String("cert_file", cl.certFile),
slog.String("key_file", cl.keyFile),
)
}
@@ -0,0 +1,212 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// generateTestCert creates a self-signed certificate and key and writes them
// to PEM files in the given directory. Returns cert and key file paths.
func generateTestCert(t *testing.T, dir, prefix string) (string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
serial := big.NewInt(time.Now().UnixNano())
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: prefix + ".example.com",
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(1 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
certFile := filepath.Join(dir, prefix+".pem")
keyFile := filepath.Join(dir, prefix+"-key.pem")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
if err := os.WriteFile(certFile, certPEM, 0600); err != nil {
t.Fatalf("failed to write cert file: %v", err)
}
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
t.Fatalf("failed to write key file: %v", err)
}
return certFile, keyFile
}
// certLoaderSerial extracts the serial number of the first certificate
// returned by the loader's getCertificate callback.
func certLoaderSerial(cl *certificateLoader) *big.Int {
cert, err := cl.getCertificate(nil)
if err != nil || cert == nil {
return nil
}
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil
}
return x509Cert.SerialNumber
}
// TestCertificateLoaderInitialLoad verifies that a new certificateLoader
// loads the certificate successfully and serves it via getCertificate.
func TestCertificateLoaderInitialLoad(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := generateTestCert(t, dir, "initial")
cl, err := newCertificateLoader(certFile, keyFile)
if err != nil {
t.Fatalf("newCertificateLoader failed: %v", err)
}
cert, err := cl.getCertificate(nil)
if err != nil {
t.Fatalf("getCertificate failed: %v", err)
}
if cert == nil {
t.Fatal("getCertificate returned nil certificate")
}
if len(cert.Certificate) == 0 {
t.Fatal("certificate chain is empty")
}
if certLoaderSerial(cl) == nil {
t.Fatal("unable to parse certificate serial")
}
}
// TestCertificateLoaderReload verifies that Reload picks up a new certificate
// written to disk.
func TestCertificateLoaderReload(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := generateTestCert(t, dir, "reload")
cl, err := newCertificateLoader(certFile, keyFile)
if err != nil {
t.Fatalf("newCertificateLoader failed: %v", err)
}
origSerial := certLoaderSerial(cl)
if origSerial == nil {
t.Fatal("unable to parse original certificate serial")
}
// Write a new certificate to the same file paths.
generateTestCert(t, dir, "reload")
cl.Reload()
newSerial := certLoaderSerial(cl)
if newSerial == nil {
t.Fatal("unable to parse certificate serial after reload")
}
if origSerial.Cmp(newSerial) == 0 {
t.Fatal("certificate serial did not change after reload")
}
}
// TestCertificateLoaderReloadFailureKeepsOldCert verifies that if Reload fails
// the old certificate is preserved.
func TestCertificateLoaderReloadFailureKeepsOldCert(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := generateTestCert(t, dir, "keep-old")
cl, err := newCertificateLoader(certFile, keyFile)
if err != nil {
t.Fatalf("newCertificateLoader failed: %v", err)
}
origSerial := certLoaderSerial(cl)
if origSerial == nil {
t.Fatal("unable to parse original certificate serial")
}
// Corrupt the key file.
if err := os.WriteFile(keyFile, []byte("not a valid PEM key"), 0600); err != nil {
t.Fatalf("failed to write corrupted key file: %v", err)
}
cl.Reload()
curSerial := certLoaderSerial(cl)
if curSerial == nil {
t.Fatal("unable to parse certificate serial after failed reload")
}
if origSerial.Cmp(curSerial) != 0 {
t.Fatal("certificate changed after a failed reload")
}
}
// TestCertificateLoaderNilClientHello verifies getCertificate handles a nil
// *tls.ClientHelloInfo argument.
func TestCertificateLoaderNilClientHello(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := generateTestCert(t, dir, "nil-hello")
cl, err := newCertificateLoader(certFile, keyFile)
if err != nil {
t.Fatalf("newCertificateLoader failed: %v", err)
}
cert, err := cl.getCertificate(nil)
if err != nil {
t.Fatalf("getCertificate(nil) returned error: %v", err)
}
if cert == nil {
t.Fatal("getCertificate(nil) returned nil")
}
}
// TestCertificateLoaderClientHelloInfo verifies that getCertificate works
// when called with a real *tls.ClientHelloInfo.
func TestCertificateLoaderClientHelloInfo(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := generateTestCert(t, dir, "sni")
cl, err := newCertificateLoader(certFile, keyFile)
if err != nil {
t.Fatalf("newCertificateLoader failed: %v", err)
}
hello := &tls.ClientHelloInfo{
ServerName: "sni.example.com",
}
cert, err := cl.getCertificate(hello)
if err != nil {
t.Fatalf("getCertificate with ClientHelloInfo failed: %v", err)
}
if cert == nil {
t.Fatal("getCertificate with ClientHelloInfo returned nil")
}
}
+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)
+8 -29
View File
@@ -6,21 +6,13 @@
package cubox // import "miniflux.app/v2/internal/integration/cubox"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiLink string
}
@@ -34,28 +26,15 @@ func (c *Client) SaveLink(entryURL string) error {
return errors.New("cubox: missing API link")
}
requestBody, err := json.Marshal(&card{
Type: "url",
Content: entryURL,
})
response, err := client.NewRequestBuilder(c.apiLink).
WithMethod(http.MethodPost).
WithJSON(&card{
Type: "url",
Content: entryURL,
}).
Do()
if err != nil {
return fmt.Errorf("cubox: unable to encode request body: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("cubox: unable to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
return fmt.Errorf("cubox: %w", err)
}
defer response.Body.Close()
+33 -50
View File
@@ -6,21 +6,15 @@
package discord // import "miniflux.app/v2/internal/integration/discord"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const discordMsgColor = 5793266
type Client struct {
@@ -33,58 +27,47 @@ func NewClient(webhookURL string) *Client {
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("discord: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("discord: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Discord notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSON(&discordMessage{
Embeds: []discordEmbed{
{
Title: "RSS feed update from Miniflux",
Color: discordMsgColor,
Fields: []discordFields{
{
Name: "Updated feed",
Value: feed.Title,
},
{
Name: "Article link",
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
},
{
Name: "Author",
Value: entry.Author,
Inline: true,
},
{
Name: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Inline: true,
},
},
},
},
}).
Do()
if err != nil {
return fmt.Errorf("discord: unable to send request: %v", err)
return fmt.Errorf("discord: %w", 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)
+11 -29
View File
@@ -5,20 +5,14 @@ package espial // import "miniflux.app/v2/internal/integration/espial"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -38,30 +32,18 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
return fmt.Errorf("espial: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
URL: entryURL,
ToRead: true,
Tags: espialTags,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&espialDocument{
Title: entryTitle,
URL: entryURL,
ToRead: true,
Tags: espialTags,
}).
WithHeader("Authorization", "ApiKey "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("espial: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("espial: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
return fmt.Errorf("espial: %w", err)
}
defer response.Body.Close()
+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})
+13 -31
View File
@@ -4,22 +4,15 @@
package linkace // import "miniflux.app/v2/internal/integration/linkace"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -45,31 +38,20 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
if err != nil {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&createItemRequest{
URL: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
}).
WithHeader("Accept", "application/json").
WithHeader("Authorization", "Bearer "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkace: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkace: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
return fmt.Errorf("linkace: %w", err)
}
defer response.Body.Close()
+11 -30
View File
@@ -4,22 +4,15 @@
package linkding // import "miniflux.app/v2/internal/integration/linkding"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -45,30 +38,18 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkdingBookmark{
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&linkdingBookmark{
URL: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
}).
WithHeader("Authorization", "Token "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkding: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkding: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
return fmt.Errorf("linkding: %w", err)
}
defer response.Body.Close()
+6 -25
View File
@@ -4,22 +4,15 @@
package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
@@ -59,25 +52,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
payload.Collection = &linkwardenCollection{ID: c.collectionID}
}
requestBody, err := json.Marshal(payload)
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(payload).
WithHeader("Authorization", "Bearer "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkwarden: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
return fmt.Errorf("linkwarden: %w", err)
}
defer response.Body.Close()
+16 -33
View File
@@ -4,19 +4,13 @@
package notion
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiToken string
pageID string
@@ -32,36 +26,25 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
}
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
requestBody, err := json.Marshal(&notionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPatch).
WithJSON(&notionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
},
},
},
},
})
}).
WithHeader("Notion-Version", "2022-06-28").
WithHeader("Authorization", "Bearer "+c.apiToken).
Do()
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("notion: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
return fmt.Errorf("notion: %w", err)
}
defer response.Body.Close()
+19 -32
View File
@@ -4,21 +4,14 @@
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
collectionID string
@@ -26,7 +19,13 @@ type Client struct {
}
func NewClient(token, collectionID, tags string) *Client {
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
var tagList []string
for tag := range strings.SplitSeq(tags, ",") {
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
tagList = append(tagList, trimmedTag)
}
}
return &Client{token: token, collectionID: collectionID, tags: tagList}
}
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
@@ -35,30 +34,18 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
return errors.New("raindrop: missing token")
}
var request *http.Request
requestBodyJson, err := json.Marshal(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
})
response, err := client.NewRequestBuilder("https://api.raindrop.io/rest/v1/raindrop").
WithMethod(http.MethodPost).
WithJSON(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
}).
WithHeader("Authorization", "Bearer "+c.token).
Do()
if err != nil {
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("raindrop: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.token)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
return fmt.Errorf("raindrop: %w", err)
}
defer response.Body.Close()
@@ -73,7 +60,7 @@ type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection"`
Tags []string `json:"tags"`
Tags []string `json:"tags,omitempty"`
}
type collection struct {
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package raindrop
import (
"encoding/json"
"slices"
"testing"
)
func TestNewClientTagParsing(t *testing.T) {
tests := []struct {
name string
tags string
want []string
}{
{name: "empty string produces no tags", tags: "", want: nil},
{name: "single tag", tags: "news", want: []string{"news"}},
{name: "multiple tags", tags: "news,tech", want: []string{"news", "tech"}},
{name: "whitespace is trimmed", tags: " news , tech ", want: []string{"news", "tech"}},
{name: "empty items are dropped", tags: "news,,tech,", want: []string{"news", "tech"}},
{name: "only separators produce no tags", tags: ", ,", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient("token", "collection", tt.tags)
if !slices.Equal(client.tags, tt.want) {
t.Errorf("NewClient(%q) tags = %#v, want %#v", tt.tags, client.tags, tt.want)
}
})
}
}
func TestPayloadOmitsEmptyTags(t *testing.T) {
payload, err := json.Marshal(&raindrop{Link: "https://example.com", Title: "Example"})
if err != nil {
t.Fatalf("unable to marshal payload: %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatalf("unable to unmarshal payload: %v", err)
}
if _, found := fields["tags"]; found {
t.Errorf("payload without tags should omit the tags field, got %s", payload)
}
}
+9 -29
View File
@@ -6,22 +6,14 @@
package readwise // import "miniflux.app/v2/internal/integration/readwise"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
const (
readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
defaultClientTimeout = 10 * time.Second
)
const readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
type Client struct {
apiKey string
@@ -36,27 +28,15 @@ func (c *Client) CreateDocument(entryURL string) error {
return errors.New("readwise: missing API key")
}
requestBody, err := json.Marshal(&readwiseDocument{
URL: entryURL,
})
response, err := client.NewRequestBuilder(readwiseApiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&readwiseDocument{
URL: entryURL,
}).
WithHeader("Authorization", "Token "+c.apiKey).
Do()
if err != nil {
return fmt.Errorf("readwise: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("readwise: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
return fmt.Errorf("readwise: %w", err)
}
defer response.Body.Close()
+11 -29
View File
@@ -4,24 +4,18 @@
package shaarli // import "miniflux.app/v2/internal/integration/shaarli"
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiSecret string
@@ -41,30 +35,18 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
return fmt.Errorf("shaarli: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addLinkRequest{
URL: entryURL,
Title: entryTitle,
Private: true,
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&addLinkRequest{
URL: entryURL,
Title: entryTitle,
Private: true,
}).
WithHeader("Accept", "application/json").
WithHeader("Authorization", "Bearer "+c.generateBearerToken()).
Do()
if err != nil {
return fmt.Errorf("shaarli: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shaarli: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shaarli: unable to send request: %v", err)
return fmt.Errorf("shaarli: %w", err)
}
defer response.Body.Close()
+20 -51
View File
@@ -4,21 +4,15 @@
package shiori // import "miniflux.app/v2/internal/integration/shiori"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
username string
@@ -44,34 +38,21 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
}).
WithHeader("Authorization", "Bearer "+token).
Do()
if err != nil {
return fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("shiori: unable to send request: %v", err)
return fmt.Errorf("shiori: %w", err)
}
defer response.Body.Close()
@@ -88,25 +69,13 @@ func (c *Client) authenticate() (string, error) {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
response, err := client.NewRequestBuilder(apiEndpoint).
WithMethod(http.MethodPost).
WithJSON(&authRequest{Username: c.username, Password: c.password, RememberMe: false}).
WithHeader("Accept", "application/json").
Do()
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("shiori: unable to send request: %v", err)
return "", fmt.Errorf("shiori: %w", err)
}
defer response.Body.Close()
+37 -54
View File
@@ -6,21 +6,15 @@
package slack // import "miniflux.app/v2/internal/integration/slack"
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
const slackMsgColor = "#5865F2"
type Client struct {
@@ -33,62 +27,51 @@ func NewClient(webhookURL string) *Client {
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
for _, entry := range entries {
requestBody, err := json.Marshal(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("slack: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("slack: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
slog.Debug("Sending Slack notification",
slog.String("webhookURL", c.webhookURL),
slog.String("title", feed.Title),
slog.String("entry_url", entry.URL),
)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSON(&slackMessage{
Attachments: []slackAttachments{
{
Title: "RSS feed update from Miniflux",
Color: slackMsgColor,
Fields: []slackFields{
{
Title: "Updated feed",
Value: feed.Title,
},
{
Title: "Article title",
Value: entry.Title,
},
{
Title: "Article link",
Value: entry.URL,
},
{
Title: "Author",
Value: entry.Author,
Short: true,
},
{
Title: "Source website",
Value: urllib.RootURL(feed.SiteURL),
Short: true,
},
},
},
},
}).
Do()
if err != nil {
return fmt.Errorf("slack: unable to send request: %v", err)
return fmt.Errorf("slack: %w", 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)
+7 -18
View File
@@ -4,23 +4,18 @@
package webhook // import "miniflux.app/v2/internal/integration/webhook"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
NewEntriesEventType = "new_entries"
SaveEntryEventType = "save_entry"
)
@@ -124,20 +119,14 @@ func (c *Client) makeRequest(eventType string, payload any) error {
return fmt.Errorf("webhook: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
response, err := client.NewRequestBuilder(c.webhookURL).
WithMethod(http.MethodPost).
WithJSONBody(requestBody).
WithHeader("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody)).
WithHeader("X-Miniflux-Event-Type", eventType).
Do()
if err != nil {
return fmt.Errorf("webhook: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
request.Header.Set("X-Miniflux-Event-Type", eventType)
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("webhook: unable to send request: %v", err)
return fmt.Errorf("webhook: %w", err)
}
defer response.Body.Close()
+26 -7
View File
@@ -7,6 +7,7 @@ import (
"embed"
"encoding/json"
"fmt"
"sync"
)
type translationDict struct {
@@ -15,19 +16,37 @@ type translationDict struct {
}
type catalog map[string]translationDict
var defaultCatalog = make(catalog, len(AvailableLanguages))
// defaultCatalog is populated lazily by getTranslationDict, which runs on
// concurrent request goroutines, so every access must hold defaultCatalogMutex.
var (
defaultCatalog = make(catalog, len(AvailableLanguages))
defaultCatalogMutex sync.RWMutex
)
//go:embed translations/*.json
var translationFiles embed.FS
func getTranslationDict(language string) (translationDict, error) {
if _, ok := defaultCatalog[language]; !ok {
var err error
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
return translationDict{}, err
}
defaultCatalogMutex.RLock()
dict, found := defaultCatalog[language]
defaultCatalogMutex.RUnlock()
if found {
return dict, nil
}
return defaultCatalog[language], nil
defaultCatalogMutex.Lock()
defer defaultCatalogMutex.Unlock()
if dict, found := defaultCatalog[language]; found {
return dict, nil
}
dict, err := loadTranslationFile(language)
if err != nil {
return translationDict{}, err
}
defaultCatalog[language] = dict
return dict, nil
}
func loadTranslationFile(language string) (translationDict, error) {
+31
View File
@@ -4,6 +4,7 @@
package locale // import "miniflux.app/v2/internal/locale"
import (
"sync"
"testing"
)
@@ -30,6 +31,35 @@ func TestParser(t *testing.T) {
}
}
// TestGetTranslationDictConcurrency exercises the lazy population of the
// catalog from concurrent goroutines, as HTTP request handlers do. It must be
// run with the race detector enabled to catch unsynchronized catalog access.
func TestGetTranslationDictConcurrency(t *testing.T) {
defaultCatalog = make(catalog, len(AvailableLanguages))
const iterations = 10
var wg sync.WaitGroup
for i := 0; i < iterations; i++ {
for language := range AvailableLanguages {
wg.Add(1)
go func(language string) {
defer wg.Done()
dict, err := getTranslationDict(language)
if err != nil {
t.Errorf(`Unable to get translation dictionary for language %q: %v`, language, err)
return
}
if len(dict.singulars) == 0 {
t.Errorf(`The translation dictionary for language %q should not be empty`, language)
}
}(language)
}
}
wg.Wait()
}
func TestLoadCatalog(t *testing.T) {
for language := range AvailableLanguages {
_, err := loadTranslationFile(language)
@@ -111,6 +141,7 @@ func TestTranslationFilePluralForms(t *testing.T) {
"id_ID": 1,
"it_IT": 2,
"ja_JP": 1,
"ko_KR": 1,
"nan_Latn_pehoeji": 1,
"nl_NL": 2,
"pl_PL": 3,
+4 -4
View File
@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
t.Errorf("Expected French translation %q, got %q", expected, result)
}
// Test with missing language (should use key as fallback with args applied)
// Test with missing language (should fall back to the untranslated key)
result = wrapper.Translate("invalid_lang")
expected = "error.test_key%!(EXTRA string=test message, int=404)"
expected = "error.test_key"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
localizedErr := NewLocalizedError("error.missing", "arg1")
result := localizedErr.String()
expected := "error.missing%!(EXTRA string=arg1)"
expected := "error.missing"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
// Test with missing language
result = localizedErr.Translate("invalid_lang")
expected = "error.permission%!(EXTRA string=admin panel)"
expected = "error.permission"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
+1
View File
@@ -17,6 +17,7 @@ var AvailableLanguages = map[string]string{
"id_ID": "Bahasa Indonesia",
"it_IT": "Italiano",
"ja_JP": "日本語",
"ko_KR": "한국어",
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
"nl_NL": "Nederlands",
"pl_PL": "Polski",
+1 -1
View File
@@ -36,7 +36,7 @@ func getPluralForm(lang string, n int) int {
return 1
}
return 0
case "id_ID", "ja_JP":
case "id_ID", "ja_JP", "ko_KR":
return 0
case "pl_PL":
switch {
+8
View File
@@ -66,6 +66,14 @@ func TestPluralRules(t *testing.T) {
5: 0,
100: 0,
},
// Korean (ko_KR) - always form 0
"ko_KR": {
0: 0,
1: 0,
2: 0,
5: 0,
100: 0,
},
// Polish (pl_PL) - 3 forms
"pl_PL": {
1: 0, // n == 1
+32 -2
View File
@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...any) string {
return fmt.Sprintf(p.Print(key), args...)
return formatTranslation(p.Print(key), args...)
}
// Plural returns the translation of the given key by using the language plural form.
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
return formatTranslation(choices[index], args...)
}
}
return key
}
// formatTranslation skips extra arguments when the translation references no argument,
// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
func formatTranslation(format string, args ...any) string {
if !hasFormattingDirective(format) {
return fmt.Sprintf(format, []any{}...)
}
return fmt.Sprintf(format, args...)
}
// hasFormattingDirective reports whether the format should be handled with the
// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
// any other percent sequence, including a dangling "%".
func hasFormattingDirective(format string) bool {
for index := 0; index < len(format); index++ {
if format[index] != '%' {
continue
}
if index+1 >= len(format) {
return true
}
if format[index+1] == '%' {
index++ // skip the escaped percent
continue
}
return true
}
return false
}
+56
View File
@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
}
}
}
func TestPluralFormWithoutPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"ar_SA": translationDict{
plurals: map[string][]string{
// The Arabic dual omits the count by design.
"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
},
},
}
printer := NewPrinter("ar_SA")
if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
}
if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
}
}
func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"media.completion": "Mark as read at 90%% completion",
},
},
}
got := NewPrinter("en_US").Printf("media.completion")
expected := "Mark as read at 90% completion"
if got != expected {
t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
}
}
func TestHasFormattingDirective(t *testing.T) {
tests := map[string]bool{
"دقيقتين": false,
"%d دقيقة": true,
"90%% done": false, // escaped percent consumes no argument
"%d of %s": true,
"": false,
"%": true,
}
for format, expected := range tests {
if got := hasFormattingDirective(format); got != expected {
t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
}
}
}
+2 -2
View File
@@ -176,7 +176,7 @@
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
"form.feed.label.ignore_entry_updates": "Updates ignorieren",
"form.feed.label.description": "Beschreibung",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
@@ -186,7 +186,7 @@
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keep_filter_entry_rules": "Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
+15 -15
View File
@@ -170,7 +170,7 @@
"form.feed.fieldset.network_settings": "Axustes da rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
"form.feed.label.apprise_service_urls": "Lista de URLs separadas por comas do servizo Apprise",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
"form.feed.label.category": "Categoría",
@@ -216,7 +216,7 @@
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar entradas a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
"form.integration.apprise_services_url": "Lista de URLs separadas por comas do servizo Apprise",
"form.integration.apprise_url": "URL de Apprise API",
"form.integration.betula_activate": "Gardar entradas en Betula",
"form.integration.betula_token": "Token de Betula",
@@ -305,9 +305,9 @@
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.raindrop_token": "Token (de proba)",
"form.integration.readeck_activate": "Gardar entradas en Readeck",
"form.integration.readeck_api_key": "Clave da Readeck API",
"form.integration.readeck_api_key": "Clave da API de Readeck",
"form.integration.readeck_endpoint": "URL de Readeck",
"form.integration.readeck_labels": "Etiquetas Readeck",
"form.integration.readeck_labels": "Etiquetas para Readeck",
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
@@ -329,27 +329,27 @@
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
"form.integration.telegram_bot_token": "Toke do Bot",
"form.integration.telegram_bot_token": "Token do Bot",
"form.integration.telegram_chat_id": "ID da parola",
"form.integration.telegram_topic_id": "ID do tema",
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
"form.integration.wallabag_client_secret": "Clave secreta en Wallabag",
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
"form.integration.wallabag_password": "Contrasinal en Wallabag",
"form.integration.wallabag_username": "Identificador en Wallabag",
"form.integration.wallabag_tags": "Etiquetas Wallabag",
"form.integration.wallabag_tags": "Etiquetas para Wallabag",
"form.integration.webhook_activate": "Activar Webhooks",
"form.integration.webhook_secret": "Clave secreta Webhooks",
"form.integration.webhook_url": "URL predeterminada Webhook",
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
"form.prefs.fieldset.application_settings": "Axustes da aplicación",
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "Lista de servidores de tipos de letra externos permitidos separados por espazos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ler artigos abrindo ligazóns externas",
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
@@ -367,11 +367,11 @@
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando acada o 90%% da reprodución",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente como lidas as entradas ao velas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Para son/vídeo, marcar como lido ao chegar ao 90%% da reprodución",
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova pestana (engade target=\"_blank\" ás ligazóns)",
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
"form.prefs.label.theme": "Decorado",
"form.prefs.label.timezone": "Zona horaria",
@@ -428,8 +428,8 @@
"menu.title": "Menú",
"menu.unread": "Sen ler",
"menu.users": "Usuarias",
"page.about.authors_label": "Autorías:",
"page.about.authors_value": "Frédéric Guillot e colaboradores",
"page.about.authors_label": "Autoría:",
"page.about.authors_value": "Frédéric Guillot e colaboradoras",
"page.about.build_date": "Data da versión:",
"page.about.credits": "Crédito",
"page.about.db_usage": "Tamaño da BDD:",
+620
View File
@@ -0,0 +1,620 @@
{
"action.cancel": "취소",
"action.download": "다운로드",
"action.edit": "편집",
"action.home_screen": "홈 화면에 추가",
"action.import": "가져오기",
"action.login": "로그인",
"action.or": "또는",
"action.remove": "삭제",
"action.remove_feed": "이 피드 삭제",
"action.save": "저장",
"action.subscribe": "피드 구독",
"action.update": "업데이트",
"alert.account_linked": "외부 계정과 연동되었습니다!",
"alert.account_unlinked": "외부 계정과의 연동이 해제되었습니다!",
"alert.background_feed_refresh": "모든 피드를 백그라운드에서 새로 고치는 중입니다. 이 작업 중에도 Miniflux를 계속 사용할 수 있습니다.",
"alert.feed_error": "이 피드에 문제가 있습니다.",
"alert.no_starred": "현재 즐겨찾기 표시된 게시물이 없습니다.",
"alert.no_category": "카테고리가 없습니다.",
"alert.no_category_entry": "이 카테고리에는 게시물이 없습니다.",
"alert.no_feed": "구독 중인 피드가 없습니다.",
"alert.no_feed_entry": "이 피드에는 게시물이 없습니다.",
"alert.no_feed_in_category": "이 카테고리에는 구독 중인 피드가 없습니다.",
"alert.no_history": "현재 기록이 없습니다.",
"alert.no_search_result": "검색 결과가 없습니다.",
"alert.no_shared_entry": "공유된 게시물이 없습니다.",
"alert.no_tag_entry": "이 태그와 일치하는 게시물이 없습니다.",
"alert.no_unread_entry": "읽지 않은 게시물이 없습니다.",
"alert.no_user": "당신이 유일한 사용자입니다.",
"alert.prefs_saved": "설정이 정상적으로 저장되었습니다!",
"alert.too_many_feeds_refresh": [
"피드 새로고침 요청이 너무 많습니다. %d분 후 다시 시도해 주세요."
],
"confirm.loading": "실행 중…",
"confirm.no": "아니요",
"confirm.question": "진행하시겠습니까?",
"confirm.question.refresh": "강제로 새로 고치시겠습니까?",
"confirm.yes": "예",
"enclosure_media_controls.seek": "탐색:",
"enclosure_media_controls.seek.title": "%s초 이동",
"enclosure_media_controls.speed": "속도:",
"enclosure_media_controls.speed.faster": "빠르게",
"enclosure_media_controls.speed.faster.title": "%sx 빠르게",
"enclosure_media_controls.speed.reset": "초기화",
"enclosure_media_controls.speed.reset.title": "속도를 1x로 초기화",
"enclosure_media_controls.speed.slower": "느리게",
"enclosure_media_controls.speed.slower.title": "%sx 느리게",
"entry.starred.toast.off": "즐겨찾기를 해제했습니다",
"entry.starred.toast.on": "즐겨찾기로 설정했습니다",
"entry.starred.toggle.off": "즐겨찾기 해제",
"entry.starred.toggle.on": "즐겨찾기 설정",
"entry.comments.label": "댓글",
"entry.comments.title": "댓글 보기",
"entry.estimated_reading_time": [
"%d분 소요"
],
"entry.external_link.label": "외부 링크",
"entry.save.completed": "완료!",
"entry.save.label": "저장",
"entry.save.title": "이 게시물 저장",
"entry.save.toast.completed": "게시물이 저장되었습니다",
"entry.scraper.completed": "완료!",
"entry.scraper.label": "다운로드",
"entry.scraper.title": "원본 내용 가져오기",
"entry.share.label": "공유",
"entry.share.title": "이 게시물 공유",
"entry.shared_entry.label": "공유하기",
"entry.shared_entry.title": "공개 링크 열기",
"entry.state.loading": "불러오는 중…",
"entry.state.saving": "저장 중…",
"entry.status.mark_as_read": "읽음으로 표시",
"entry.status.mark_as_unread": "읽지 않음으로 표시",
"entry.status.title": "게시물 상태 변경",
"entry.status.toast.read": "읽음으로 표시했습니다",
"entry.status.toast.unread": "읽지 않음으로 표시했습니다",
"entry.tags.label": "태그:",
"entry.tags.more_tags_label": [
"태그 %d개"
],
"entry.unshare.label": "공유 해제",
"error.api_key_already_exists": "이 API 키는 이미 존재합니다.",
"error.bad_credentials": "사용자명 또는 비밀번호가 잘못되었습니다.",
"error.category_already_exists": "이 카테고리는 이미 존재합니다.",
"error.category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.database_error": "데이터베이스 오류: %v.",
"error.different_passwords": "비밀번호가 일치하지 않습니다.",
"error.duplicate_fever_username": "같은 Fever 사용자명이 이미 사용 중입니다!",
"error.duplicate_googlereader_username": "같은 Google Reader 사용자명이 이미 사용 중입니다!",
"error.duplicate_linked_account": "다른 사용자가 이미 이 서비스의 동일한 사용자와 연동되어 있습니다.",
"error.duplicated_feed": "이 피드는 이미 존재합니다.",
"error.empty_file": "이 파일은 비어 있습니다.",
"error.entries_per_page_invalid": "페이지당 게시물 수가 유효하지 않습니다.",
"error.feed_already_exists": "이 피드는 이미 존재합니다.",
"error.feed_category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_format_not_detected": "피드 형식을 감지할 수 없습니다: %v.",
"error.feed_invalid_blocklist_rule": "차단 목록 규칙이 유효하지 않습니다.",
"error.feed_invalid_keeplist_rule": "허용 목록 규칙이 유효하지 않습니다.",
"error.feed_mandatory_fields": "URL과 카테고리가 필요합니다.",
"error.feed_not_found": "이 피드는 존재하지 않거나 이 사용자의 것이 아닙니다.",
"error.feed_title_not_empty": "피드 제목은 비워 둘 수 없습니다.",
"error.feed_url_not_empty": "피드 URL은 비워 둘 수 없습니다.",
"error.fields_mandatory": "모든 항목을 입력해주세요.",
"error.http_bad_gateway": "잘못된 게이트웨이 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 나중에 다시 시도해 주세요.",
"error.http_body_read": "HTTP 본문을 읽을 수 없습니다: %v.",
"error.http_client_error": "HTTP 클라이언트 오류: %v.",
"error.http_cloudflare_challenge": "이 웹사이트는 Cloudflare 봇 챌린지(CAPTCHA 또는 JavaScript 검증)로 보호되어 있습니다. Miniflux는 이 챌린지를 자동으로 해결할 수 없습니다.",
"error.http_empty_response": "HTTP 응답이 비어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_empty_response_body": "HTTP 응답 본문이 비어 있습니다.",
"error.http_forbidden": "이 웹사이트에 대한 접근이 금지되어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
"error.http_gateway_timeout": "게이트웨이 시간 초과로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_internal_server_error": "서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_not_authorized": "이 웹사이트에 대한 접근이 허용되지 않았습니다. 사용자명 또는 비밀번호가 올바르지 않을 수 있습니다.",
"error.http_resource_not_found": "요청한 리소스를 찾을 수 없습니다. URL을 확인해 주세요.",
"error.http_response_too_large": "HTTP 응답이 너무 큽니다. 전역 설정에서 HTTP 응답 크기 제한을 늘릴 수 있습니다. (서버 재시작 필요)",
"error.http_service_unavailable": "내부 서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. 문제는 Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.http_too_many_requests": "Miniflux가 이 웹사이트에 너무 많은 요청을 보냈습니다. 잠시 기다리거나 애플리케이션 설정을 변경해 주세요.",
"error.http_unexpected_status_code": "예상치 못한 HTTP 상태 코드(%d)로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
"error.invalid_categories_sorting_order": "카테고리 표시 순서가 유효하지 않습니다.",
"error.invalid_default_home_page": "기본 시작 페이지가 유효하지 않습니다",
"error.invalid_display_mode": "웹 앱 표시 모드가 유효하지 않습니다.",
"error.invalid_entry_direction": "게시물 표시 방향이 유효하지 않습니다.",
"error.invalid_entry_order": "게시물 표시 순서가 유효하지 않습니다.",
"error.invalid_feed_proxy_url": "프록시 URL이 유효하지 않습니다.",
"error.invalid_feed_url": "피드 URL이 유효하지 않습니다.",
"error.invalid_gesture_nav": "제스처 내비게이션이 유효하지 않습니다.",
"error.invalid_language": "언어가 유효하지 않습니다.",
"error.invalid_site_url": "사이트 URL이 유효하지 않습니다.",
"error.invalid_theme": "테마가 유효하지 않습니다.",
"error.invalid_timezone": "시간대가 유효하지 않습니다.",
"error.network_operation": "네트워크 오류로 인해 Miniflux가 이 웹사이트에 도달할 수 없습니다: %v.",
"error.network_timeout": "이 웹사이트의 응답이 너무 느려 시간 초과되었습니다: %v",
"error.password_min_length": "비밀번호는 6자 이상이어야 합니다.",
"error.proxy_url_not_empty": "프록시 URL은 비워 둘 수 없습니다.",
"error.settings_block_rule_fieldname_invalid": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_block_rule_invalid_regex": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_block_rule_regex_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_block_rule_separator_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_invalid_domain_list": "도메인 목록이 유효하지 않습니다. 도메인은 공백으로 구분해 지정해 주세요.",
"error.settings_keep_rule_fieldname_invalid": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
"error.settings_keep_rule_invalid_regex": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
"error.settings_keep_rule_regex_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
"error.settings_keep_rule_separator_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
"error.settings_mandatory_fields": "사용자명, 테마, 언어, 시간대가 모두 필요합니다.",
"error.settings_media_playback_rate_range": "재생 속도가 범위를 벗어났습니다",
"error.settings_reading_speed_is_positive": "읽기 속도는 양의 정수여야 합니다.",
"error.site_url_not_empty": "사이트 URL은 비워 둘 수 없습니다.",
"error.subscription_not_found": "피드를 찾을 수 없습니다.",
"error.title_required": "제목이 필요합니다.",
"error.tls_error": "TLS 오류: %q. 필요한 경우 피드 설정에서 TLS 검증을 비활성화할 수 있습니다.",
"error.unable_to_create_api_key": "이 API 키를 만들 수 없습니다.",
"error.unable_to_create_category": "이 카테고리를 만들 수 없습니다.",
"error.unable_to_create_user": "이 사용자를 만들 수 없습니다.",
"error.unable_to_detect_rssbridge": "RSS-Bridge를 사용해 피드를 감지할 수 없습니다: %v.",
"error.unable_to_parse_feed": "이 피드를 파싱할 수 없습니다: %v.",
"error.unable_to_update_category": "이 카테고리를 업데이트할 수 없습니다.",
"error.unable_to_update_feed": "이 피드를 업데이트할 수 없습니다.",
"error.unable_to_update_user": "이 사용자를 업데이트할 수 없습니다.",
"error.unlink_account_without_password": "비밀번호를 설정하지 않으면 다시 로그인할 수 없습니다.",
"error.user_already_exists": "이 사용자는 이미 존재합니다.",
"error.user_mandatory_fields": "사용자명이 필요합니다.",
"error.linktaco_missing_required_fields": "LinkTaco API 토큰과 조직 슬러그가 필요합니다",
"form.api_key.label.description": "API키 설명",
"form.category.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.category.label.title": "제목",
"form.feed.fieldset.general": "일반",
"form.feed.fieldset.integration": "서드파티 서비스",
"form.feed.fieldset.network_settings": "네트워크 설정",
"form.feed.fieldset.rules": "규칙",
"form.feed.label.allow_self_signed_certificates": "자체 서명 인증서 또는 유효하지 않은 인증서 허용",
"form.feed.label.apprise_service_urls": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.feed.label.block_filter_entry_rules": "게시물 차단 규칙",
"form.feed.label.blocklist_rules": "정규식 기반 차단 필터",
"form.feed.label.category": "카테고리",
"form.feed.label.cookie": "Cookie 설정",
"form.feed.label.crawler": "게시물 본문도 함께 다운로드",
"form.feed.label.ignore_entry_updates": "업데이트된 게시물 무시",
"form.feed.label.description": "설명",
"form.feed.label.disable_http2": "핑거프린팅 회피를 위해 HTTP/2 비활성화",
"form.feed.label.disabled": "이 피드를 업데이트하지 않음",
"form.feed.label.feed_password": "피드 비밀번호",
"form.feed.label.feed_url": "피드 URL",
"form.feed.label.feed_username": "피드 사용자명",
"form.feed.label.fetch_via_proxy": "애플리케이션 수준에서 설정된 프록시 사용",
"form.feed.label.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
"form.feed.label.ignore_http_cache": "HTTP 캐시 무시",
"form.feed.label.keep_filter_entry_rules": "게시물 허용 규칙",
"form.feed.label.keeplist_rules": "정규식 기반 보존 필터",
"form.feed.label.no_media_player": "미디어 기능 비활성화 (오디오/비디오)",
"form.feed.label.ntfy_activate": "게시물을 ntfy로 전송",
"form.feed.label.ntfy_default_priority": "ntfy 기본 우선순위",
"form.feed.label.ntfy_high_priority": "ntfy 높은 우선순위",
"form.feed.label.ntfy_low_priority": "ntfy 낮은 우선순위",
"form.feed.label.ntfy_max_priority": "ntfy 최대 우선순위",
"form.feed.label.ntfy_min_priority": "ntfy 최소 우선순위",
"form.feed.label.ntfy_priority": "ntfy 우선순위",
"form.feed.label.ntfy_topic": "ntfy 토픽(선택 사항)",
"form.feed.label.proxy_url": "프록시 URL",
"form.feed.label.pushover_activate": "게시물을 pushover.net으로 전송",
"form.feed.label.pushover_default_priority": "Pushover 기본 우선순위",
"form.feed.label.pushover_high_priority": "Pushover 높은 우선순위",
"form.feed.label.pushover_low_priority": "Pushover 낮은 우선순위",
"form.feed.label.pushover_max_priority": "Pushover 최대 우선순위",
"form.feed.label.pushover_min_priority": "Pushover 최소 우선순위",
"form.feed.label.pushover_priority": "Pushover 메시지 우선순위",
"form.feed.label.rewrite_rules": "본문 재작성 규칙",
"form.feed.label.scraper_rules": "본문 추출 규칙",
"form.feed.label.site_url": "사이트 URL",
"form.feed.label.title": "제목",
"form.feed.label.urlrewrite_rules": "URL 재작성 규칙",
"form.feed.label.user_agent": "기본 User Agent 덮어쓰기",
"form.feed.label.webhook_url": "Webhook URL 덮어쓰기",
"form.import.label.file": "OPML 파일",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "게시물을 archive.org로 푸시",
"form.integration.apprise_activate": "게시물을 Apprise로 전송",
"form.integration.apprise_services_url": "Apprise 서비스 URL의 쉼표로 구분된 목록",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "게시물을 Betula에 저장",
"form.integration.betula_token": "Betula 토큰",
"form.integration.betula_url": "Betula 서버 URL",
"form.integration.cubox_activate": "게시물을 Cubox에 저장",
"form.integration.cubox_api_link": "Cubox API 링크",
"form.integration.discord_activate": "게시물을 Discord로 전송",
"form.integration.discord_webhook_link": "Discord Webhook 링크",
"form.integration.espial_activate": "Espial에 게시물 저장",
"form.integration.espial_api_key": "Espial API 키",
"form.integration.espial_endpoint": "Espial API 엔드포인트",
"form.integration.espial_tags": "Espial 태그",
"form.integration.fever_activate": "Fever API 활성화",
"form.integration.fever_endpoint": "Fever API 엔드포인트:",
"form.integration.fever_password": "Fever 비밀번호",
"form.integration.fever_username": "Fever 사용자명",
"form.integration.googlereader_activate": "Google Reader API 활성화",
"form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
"form.integration.googlereader_password": "Google Reader 비밀번호",
"form.integration.googlereader_username": "Google Reader 사용자명",
"form.integration.instapaper_activate": "Instapaper에 게시물 저장",
"form.integration.instapaper_password": "Instapaper 비밀번호",
"form.integration.instapaper_username": "Instapaper 사용자명",
"form.integration.karakeep_activate": "Karakeep에 게시물 저장",
"form.integration.karakeep_api_key": "Karakeep API 키",
"form.integration.karakeep_url": "Karakeep API 엔드포인트",
"form.integration.karakeep_tags": "Karakeep 태그",
"form.integration.linkace_activate": "게시물을 LinkAce에 저장",
"form.integration.linkace_api_key": "LinkAce API 키",
"form.integration.linkace_check_disabled": "링크 확인 비활성화",
"form.integration.linkace_endpoint": "LinkAce API 엔드포인트",
"form.integration.linkace_is_private": "링크를 비공개로 설정",
"form.integration.linkace_tags": "LinkAce 태그",
"form.integration.linkding_activate": "Linkding에 게시물 저장",
"form.integration.linkding_api_key": "Linkding API 키",
"form.integration.linkding_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.linkding_endpoint": "Linkding API 엔드포인트",
"form.integration.linkding_tags": "Linkding 태그",
"form.integration.linktaco_activate": "LinkTaco에 게시물 저장",
"form.integration.linktaco_api_token": "LinkTaco API 토큰",
"form.integration.linktaco_api_token_hint": "개인용 액세스 토큰 받기",
"form.integration.linktaco_org_slug": "조직 슬러그",
"form.integration.linktaco_tags": "태그(최대 10개, 쉼표로 구분)",
"form.integration.linktaco_tags_hint": "최대 10개의 태그, 쉼표로 구분",
"form.integration.linktaco_visibility": "공개 설정",
"form.integration.linktaco_visibility_public": "공개",
"form.integration.linktaco_visibility_private": "비공개",
"form.integration.linktaco_visibility_hint": "비공개 설정에는 유료 LinkTaco 계정이 필요합니다",
"form.integration.linkwarden_activate": "Linkwarden에 게시물 저장",
"form.integration.linkwarden_api_key": "Linkwarden API 키",
"form.integration.linkwarden_endpoint": "Linkwarden 기본 URL",
"form.integration.linkwarden_collection_id": "Linkwarden 컬렉션 ID",
"form.integration.matrix_bot_activate": "새 게시물을 Matrix로 전달",
"form.integration.matrix_bot_chat_id": "Matrix 룸 ID",
"form.integration.matrix_bot_password": "Matrix 사용자 비밀번호",
"form.integration.matrix_bot_url": "Matrix 서버 URL",
"form.integration.matrix_bot_user": "Matrix 사용자명",
"form.integration.notion_activate": "게시물을 Notion에 저장",
"form.integration.notion_page_id": "Notion 페이지 ID",
"form.integration.notion_token": "Notion 시크릿 토큰",
"form.integration.ntfy_activate": "게시물을 ntfy로 전송",
"form.integration.ntfy_api_token": "ntfy API 토큰(선택 사항)",
"form.integration.ntfy_icon_url": "ntfy 아이콘 URL(선택 사항)",
"form.integration.ntfy_internal_links": "클릭 시 내부 링크 사용(선택 사항)",
"form.integration.ntfy_password": "ntfy 비밀번호(선택 사항)",
"form.integration.ntfy_topic": "ntfy 토픽(피드에 설정되어 있지 않으면 기본값)",
"form.integration.ntfy_url": "ntfy URL(선택 사항, 기본값 ntfy.sh)",
"form.integration.ntfy_username": "ntfy 사용자명(선택 사항)",
"form.integration.nunux_keeper_activate": "Nunux Keeper에 게시물 저장",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 키",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 엔드포인트",
"form.integration.omnivore_activate": "Omnivore에 게시물 저장",
"form.integration.omnivore_api_key": "Omnivore API 키",
"form.integration.omnivore_url": "Omnivore API 엔드포인트",
"form.integration.pinboard_activate": "Pinboard에 게시물 저장",
"form.integration.pinboard_bookmark": "북마크를 읽지 않음으로 표시",
"form.integration.pinboard_tags": "Pinboard 태그",
"form.integration.pinboard_token": "Pinboard API 토큰",
"form.integration.pushover_activate": "게시물을 Pushover로 전송",
"form.integration.pushover_device": "Pushover 기기(선택 사항)",
"form.integration.pushover_prefix": "Pushover URL 접두사(선택 사항)",
"form.integration.pushover_token": "Pushover 앱 API 토큰",
"form.integration.pushover_user": "Pushover 사용자 키",
"form.integration.raindrop_activate": "게시물을 Raindrop에 저장",
"form.integration.raindrop_collection_id": "컬렉션 ID",
"form.integration.raindrop_tags": "태그(쉼표로 구분)",
"form.integration.raindrop_token": "(테스트) 토큰",
"form.integration.readeck_activate": "Readeck에 게시물 저장",
"form.integration.readeck_api_key": "Readeck API 키",
"form.integration.readeck_endpoint": "Readeck API 엔드포인트",
"form.integration.readeck_labels": "Readeck 라벨",
"form.integration.readeck_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.readeck_push_activate": "새 게시물을 자동으로 Readeck에 전송",
"form.integration.readwise_activate": "Readwise Reader에 게시물 저장",
"form.integration.readwise_api_key": "Readwise Reader 액세스 토큰",
"form.integration.readwise_api_key_link": "Readwise 액세스 토큰 받기",
"form.integration.rssbridge_activate": "구독 추가 시 RSS-Bridge 확인",
"form.integration.rssbridge_token": "RSS-Bridge 인증 토큰",
"form.integration.rssbridge_url": "RSS-Bridge 서버 URL",
"form.integration.shaarli_activate": "게시물을 Shaarli에 저장",
"form.integration.shaarli_api_secret": "Shaarli API 시크릿",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "게시물을 Shiori에 저장",
"form.integration.shiori_endpoint": "Shiori API 엔드포인트",
"form.integration.shiori_password": "Shiori 비밀번호",
"form.integration.shiori_username": "Shiori 사용자명",
"form.integration.slack_activate": "게시물을 Slack으로 전송",
"form.integration.slack_webhook_link": "Slack Webhook 링크",
"form.integration.telegram_bot_activate": "새 게시물을 Telegram 채팅으로 푸시",
"form.integration.telegram_bot_disable_buttons": "버튼 비활성화",
"form.integration.telegram_bot_disable_notification": "알림 비활성화",
"form.integration.telegram_bot_disable_web_page_preview": "웹 페이지 미리보기 비활성화",
"form.integration.telegram_bot_token": "봇 토큰",
"form.integration.telegram_chat_id": "채팅 ID",
"form.integration.telegram_topic_id": "토픽 ID",
"form.integration.wallabag_activate": "Wallabag에 게시물 저장",
"form.integration.wallabag_client_id": "Wallabag 클라이언트 ID",
"form.integration.wallabag_client_secret": "Wallabag 클라이언트 시크릿",
"form.integration.wallabag_endpoint": "Wallabag 기본 URL",
"form.integration.wallabag_only_url": "URL만 전송(전체 콘텐츠가 아님)",
"form.integration.wallabag_password": "Wallabag 비밀번호",
"form.integration.wallabag_username": "Wallabag 사용자명",
"form.integration.wallabag_tags": "Wallabag 태그",
"form.integration.webhook_activate": "Webhook 활성화",
"form.integration.webhook_secret": "Webhook 시크릿",
"form.integration.webhook_url": "기본 Webhook URL",
"form.prefs.fieldset.application_settings": "애플리케이션 설정",
"form.prefs.fieldset.authentication_settings": "비밀번호 인증",
"form.prefs.fieldset.google_authentication": "Google 인증",
"form.prefs.fieldset.oidc_authentication": "%s 인증",
"form.prefs.fieldset.global_feed_settings": "전역 피드 설정",
"form.prefs.fieldset.reader_settings": "리더 설정",
"form.prefs.help.external_font_hosts": "허용할 외부 폰트 호스트를 공백으로 구분해 지정합니다. 예: \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "외부 링크를 열어 게시물 읽기",
"form.prefs.label.categories_sorting_order": "카테고리 표시 순서",
"form.prefs.label.cjk_reading_speed": "한국어, 일본어, 중국어 읽기 속도 (문자/분)",
"form.prefs.label.custom_css": "사용자 지정 CSS",
"form.prefs.label.custom_js": "사용자 지정 JavaScript",
"form.prefs.label.default_home_page": "기본 시작 페이지",
"form.prefs.label.default_reading_speed": "다른 언어의 읽기 속도(단어/분)",
"form.prefs.label.display_mode": "프로그레시브 웹 앱(PWA) 표시 모드",
"form.prefs.label.entries_per_page": "페이지당 게시물 수",
"form.prefs.label.entry_order": "게시물 표시 순서 기준",
"form.prefs.label.entry_sorting": "게시물 표시 순서",
"form.prefs.label.entry_swipe": "터치스크린에서 스와이프 입력 활성화",
"form.prefs.label.external_font_hosts": "외부 폰트 호스트",
"form.prefs.label.gesture_nav": "게시물 간 이동 제스처",
"form.prefs.label.keyboard_shortcuts": "키보드 단축키 활성화",
"form.prefs.label.language": "언어",
"form.prefs.label.mark_read_manually": "수동으로 읽음 처리",
"form.prefs.label.mark_read_on_media_completion": "오디오/비디오 재생이 90%%에 도달하면 읽음 처리",
"form.prefs.label.mark_read_on_view": "표시할 때 게시물을 자동으로 읽음으로 표시",
"form.prefs.label.mark_read_on_view_or_media_completion": "표시할 때 읽음 처리. 오디오/비디오는 90%% 재생 시 읽음 처리",
"form.prefs.label.media_playback_rate": "오디오/비디오 재생 속도",
"form.prefs.label.open_external_links_in_new_tab": "외부 링크를 새 탭에서 열기(링크에 target=\"_blank\" 추가)",
"form.prefs.label.show_reading_time": "게시물 예상 읽기 시간 표시",
"form.prefs.label.theme": "테마",
"form.prefs.label.timezone": "시간대",
"form.prefs.select.alphabetical": "알파벳순",
"form.prefs.select.browser": "브라우저형",
"form.prefs.select.created_time": "게시물 가져온 시각",
"form.prefs.select.fullscreen": "전체 화면",
"form.prefs.select.minimal_ui": "미니멀 UI",
"form.prefs.select.none": "없음",
"form.prefs.select.older_first": "오래된 게시물 먼저",
"form.prefs.select.publish_time": "게시물 공개 시각",
"form.prefs.select.recent_first": "새 게시물 먼저",
"form.prefs.select.standalone": "독립형",
"form.prefs.select.swipe": "스와이프",
"form.prefs.select.tap": "더블 탭",
"form.prefs.select.unread_count": "읽지 않은 항목 수",
"form.submit.loading": "불러오는 중…",
"form.submit.saving": "저장 중…",
"form.user.label.admin": "관리자",
"form.user.label.confirmation": "비밀번호 확인",
"form.user.label.password": "비밀번호",
"form.user.label.username": "사용자명",
"menu.about": "소프트웨어 정보",
"menu.add_feed": "피드 구독",
"menu.add_user": "사용자 추가",
"menu.api_keys": "API 키",
"menu.categories": "카테고리",
"menu.create_api_key": "새 API 키 만들기",
"menu.create_category": "카테고리 만들기",
"menu.edit_category": "편집",
"menu.edit_feed": "편집",
"menu.export": "내보내기",
"menu.feed_entries": "게시물 목록",
"menu.feeds": "피드 목록",
"menu.flush_history": "기록 지우기",
"menu.history": "기록",
"menu.home_page": "홈페이지",
"menu.import": "가져오기",
"menu.integrations": "연동",
"menu.logout": "로그아웃",
"menu.mark_all_as_read": "모두 읽음으로 표시",
"menu.mark_page_as_read": "이 페이지를 읽음으로 표시",
"menu.preferences": "설정 정보",
"menu.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"menu.refresh_feed": "새로고침",
"menu.search": "검색",
"menu.sessions": "세션",
"menu.settings": "설정",
"menu.shared_entries": "공유 게시물",
"menu.show_all_entries": "모든 게시물 표시",
"menu.show_only_starred_entries": "즐겨찾기만 표시",
"menu.show_only_unread_entries": "읽지 않은 게시물만 표시",
"menu.starred": "즐겨찾기",
"menu.title": "메뉴",
"menu.unread": "읽지 않음",
"menu.users": "사용자 목록",
"page.about.authors_label": "작성자:",
"page.about.authors_value": "Frédéric Guillot 및 기여자",
"page.about.build_date": "빌드 일시:",
"page.about.credits": "저작권 표시",
"page.about.db_usage": "데이터베이스 크기:",
"page.about.git_commit": "Git 커밋:",
"page.about.global_config_options": "전역 설정 옵션",
"page.about.go_version": "Go 버전:",
"page.about.license": "라이선스:",
"page.about.postgres_version": "Postgres 버전:",
"page.about.title": "소프트웨어 정보",
"page.about.version": "버전:",
"page.add_feed.choose_feed": "피드 선택",
"page.add_feed.label.url": "피드 URL",
"page.add_feed.legend.advanced_options": "고급 설정",
"page.add_feed.no_category": "카테고리가 없습니다. 카테고리가 최소 1개 필요합니다.",
"page.add_feed.submit": "피드 탐색 및 추가",
"page.add_feed.title": "새 피드",
"page.api_keys.never_used": "사용된 적 없음",
"page.api_keys.table.actions": "액션",
"page.api_keys.table.created_at": "생성일",
"page.api_keys.table.description": "설명",
"page.api_keys.table.last_used_at": "마지막 사용",
"page.api_keys.table.token": "토큰",
"page.api_keys.title": "API 키",
"page.categories.entries": "게시물 목록",
"page.categories.feed_count": [
"피드가 %d개 있습니다."
],
"page.categories.feeds": "피드 목록",
"page.categories.no_feed": "피드가 없습니다.",
"page.categories.title": "카테고리",
"page.categories_count": [
"카테고리 %d개"
],
"page.category_label": "카테고리: %s",
"page.edit_category.title": "카테고리 편집: %s",
"page.edit_feed.etag_header": "ETag 헤더:",
"page.edit_feed.last_check": "마지막 확인:",
"page.edit_feed.last_modified_header": "Last-Modified 헤더:",
"page.edit_feed.last_parsing_error": "최근 파싱 오류",
"page.edit_feed.no_header": "없음",
"page.edit_feed.title": "피드 편집: %s",
"page.edit_user.title": "사용자 편집: %s",
"page.entry.attachments": "첨부 파일",
"page.feeds.error_count": [
"오류 %d개"
],
"page.feeds.last_check": "마지막 확인:",
"page.feeds.next_check": "다음 확인:",
"page.feeds.read_counter": "읽은 게시물 수",
"page.feeds.title": "피드 목록",
"page.footer.elevator": "페이지 맨 위로 올라가기",
"page.history.title": "기록",
"page.import.title": "가져오기",
"page.integration.bookmarklet": "북마크릿",
"page.integration.bookmarklet.help": "이 특별한 링크를 사용하면 브라우저에서 직접 웹사이트의 피드를 구독할 수 있습니다.",
"page.integration.bookmarklet.instructions": "이 링크를 브라우저 북마크로 드래그하세요.",
"page.integration.bookmarklet.name": "Miniflux에 추가",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 엔드포인트",
"page.integration.miniflux_api_password": "비밀번호",
"page.integration.miniflux_api_password_value": "계정 비밀번호",
"page.integration.miniflux_api_username": "사용자명",
"page.integrations.title": "연동",
"page.keyboard_shortcuts.close_modal": "모달 대화상자 닫기",
"page.keyboard_shortcuts.download_content": "원본 내용 다운로드",
"page.keyboard_shortcuts.go_to_bottom_item": "가장 아래 게시물로 이동",
"page.keyboard_shortcuts.go_to_categories": "카테고리",
"page.keyboard_shortcuts.go_to_feed": "피드",
"page.keyboard_shortcuts.go_to_feeds": "피드 목록",
"page.keyboard_shortcuts.go_to_history": "기록",
"page.keyboard_shortcuts.go_to_next_item": "다음 게시물",
"page.keyboard_shortcuts.go_to_next_page": "다음 페이지",
"page.keyboard_shortcuts.go_to_previous_item": "이전 게시물",
"page.keyboard_shortcuts.go_to_previous_page": "이전 페이지",
"page.keyboard_shortcuts.go_to_search": "검색 폼으로 이동",
"page.keyboard_shortcuts.go_to_settings": "설정",
"page.keyboard_shortcuts.go_to_starred": "즐겨찾기",
"page.keyboard_shortcuts.go_to_top_item": "맨 위 게시물로 이동",
"page.keyboard_shortcuts.go_to_unread": "읽지 않음",
"page.keyboard_shortcuts.mark_page_as_read": "현재 페이지의 게시물을 모두 읽음으로 표시",
"page.keyboard_shortcuts.open_comments": "댓글 링크 열기",
"page.keyboard_shortcuts.open_comments_same_window": "현재 탭에서 댓글 링크 열기",
"page.keyboard_shortcuts.open_item": "선택한 게시물 열기",
"page.keyboard_shortcuts.open_original": "원본 링크 열기",
"page.keyboard_shortcuts.open_original_same_window": "현재 탭에서 원본 링크 열기",
"page.keyboard_shortcuts.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
"page.keyboard_shortcuts.remove_feed": "이 피드 삭제",
"page.keyboard_shortcuts.save_article": "게시물 저장",
"page.keyboard_shortcuts.scroll_item_to_top": "게시물이 상단에 오도록 스크롤",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "키보드 단축키 표시",
"page.keyboard_shortcuts.subtitle.actions": "작업",
"page.keyboard_shortcuts.subtitle.items": "게시물 간 이동",
"page.keyboard_shortcuts.subtitle.pages": "페이지 간 이동",
"page.keyboard_shortcuts.subtitle.sections": "섹션 이동",
"page.keyboard_shortcuts.title": "키보드 단축키",
"page.keyboard_shortcuts.toggle_star_status": "즐겨찾기 표시/해제",
"page.keyboard_shortcuts.toggle_entry_attachments": "첨부 파일 열기/닫기",
"page.keyboard_shortcuts.toggle_read_status_next": "읽음/읽지 않음 전환 후 다음 게시물로 이동",
"page.keyboard_shortcuts.toggle_read_status_prev": "읽음/읽지 않음 전환 후 이전 게시물로 이동",
"page.login.google_signin": "Google 계정으로 로그인",
"page.login.oidc_signin": "%s 계정으로 로그인",
"page.login.title": "로그인",
"page.login.webauthn_login": "패스키로 로그인",
"page.login.webauthn_login.error": "패스키로 로그인할 수 없음",
"page.new_api_key.title": "새 API 키",
"page.new_category.title": "새 카테고리",
"page.new_user.title": "새 사용자",
"page.offline.message": "오프라인입니다",
"page.offline.refresh_page": "페이지를 새로 고쳐 보세요",
"page.offline.title": "오프라인 모드",
"page.read_entry_count": [
"읽은 게시물 %d개"
],
"page.search.title": "검색 결과",
"page.sessions.table.actions": "작업",
"page.sessions.table.current_session": "현재 세션",
"page.sessions.table.date": "날짜",
"page.sessions.table.ip": "IP 주소",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "세션",
"page.settings.link_google_account": "Google 계정과 연동",
"page.settings.link_oidc_account": "%s 계정과 연동",
"page.settings.title": "설정",
"page.settings.unlink_google_account": "Google 계정과 연동 해제",
"page.settings.unlink_oidc_account": "%s 계정과 연동 해제",
"page.settings.webauthn.actions": "작업",
"page.settings.webauthn.added_on": "추가일",
"page.settings.webauthn.delete": [
"패스키 %d개 삭제"
],
"page.settings.webauthn.last_seen_on": "마지막 사용일",
"page.settings.webauthn.passkey_name": "패스키 이름",
"page.settings.webauthn.passkeys": "패스키 인증",
"page.settings.webauthn.register": "패스키 등록",
"page.settings.webauthn.register.error": "패스키를 등록할 수 없습니다",
"page.shared_entries.title": "공유 게시물",
"page.shared_entries_count": [
"공유 게시물 %d개"
],
"page.starred.title": "즐겨찾기",
"page.starred_entry_count": [
"즐겨찾기 표시된 게시물 %d개"
],
"page.total_entry_count": [
"총 게시물 %d개"
],
"page.unread.title": "읽지 않음",
"page.unread_entry_count": [
"읽지 않은 게시물 %d개"
],
"page.users.actions": "작업",
"page.users.admin.no": "아니오",
"page.users.admin.yes": "예",
"page.users.is_admin": "관리자",
"page.users.last_login": "마지막 로그인",
"page.users.never_logged": "로그인 기록 없음",
"page.users.title": "사용자 목록",
"page.users.username": "사용자명",
"page.webauthn_rename.title": "패스키 이름 변경",
"pagination.first": "처음",
"pagination.last": "마지막",
"pagination.next": "다음",
"pagination.previous": "이전",
"search.label": "검색",
"search.placeholder": "… 검색",
"search.submit": "검색",
"skip_to_content": "콘텐츠로 건너뛰기",
"time_elapsed.days": [
"%d일 전"
],
"time_elapsed.hours": [
"%d시간 전"
],
"time_elapsed.minutes": [
"%d분 전"
],
"time_elapsed.months": [
"%d개월 전"
],
"time_elapsed.not_yet": "미래",
"time_elapsed.now": "지금",
"time_elapsed.weeks": [
"%d주 전"
],
"time_elapsed.years": [
"%d년 전"
],
"time_elapsed.yesterday": "어제",
"tooltip.keyboard_shortcuts": "키보드 단축키: %s",
"tooltip.logged_user": "%s로 로그인 중"
}
+6
View File
@@ -19,6 +19,10 @@ const (
// and for the user "entries_per_page" preference.
const MaxEntryLimit = 1000
// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
// for the entry ID list endpoints.
const MaxEntryIDsLimit = 10000
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
@@ -29,6 +33,7 @@ type Entry struct {
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Language string `json:"language"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
@@ -76,6 +81,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.
+1
View File
@@ -28,6 +28,7 @@ type Feed struct {
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
Language string `json:"language"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
-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
}
+12
View File
@@ -13,6 +13,12 @@ import (
type atom03Feed struct {
Version string `xml:"version,attr"`
// Language is the natural language of the feed, declared by an
// xml:lang attribute on the atom:feed element. The tag is
// namespace-qualified so that lang attributes from other namespaces
// cannot override the real xml:lang value.
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
@@ -47,6 +53,12 @@ type atom03Entry struct {
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
ID string `xml:"id"`
// Language is the natural language of the entry, declared by an
// xml:lang attribute on the atom:entry element. The tag is
// namespace-qualified so that lang attributes from other namespaces
// cannot override the real xml:lang value.
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
+28 -9
View File
@@ -5,11 +5,13 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/language"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
@@ -19,7 +21,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 +32,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 +40,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.
@@ -47,9 +48,19 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
feed.Title = feed.SiteURL
}
feed.Language = language.Normalize(a.atomFeed.Language)
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry language. xml:lang applies to the whole
// subtree it is declared on, so an entry without its own
// xml:lang inherits the feed-level value.
entry.Language = language.Normalize(atomEntry.Language)
if entry.Language == "" {
entry.Language = language.Normalize(a.atomFeed.Language)
}
// Populate the entry URL.
entry.URL = atomEntry.Links.originalLink()
if entry.URL != "" {
@@ -69,6 +80,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 +93,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()
}
+44
View File
@@ -294,3 +294,47 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseAtom03WithLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#" xmlns:foo="http://example.org/ns" xml:lang="fr-CA" foo:lang="zz">
<title>dive into mark</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
<modified>2003-12-13T18:30:02Z</modified>
<entry xml:lang="pt-BR" foo:lang="zz">
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
<issued>2003-12-13T08:29:29-04:00</issued>
<modified>2003-12-13T18:30:02Z</modified>
</entry>
<entry>
<title>Atom 0.3 second snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/14/atom03"/>
<id>tag:diveintomark.org,2003:3.2398</id>
<issued>2003-12-14T08:29:29-04:00</issued>
<modified>2003-12-14T18:30:02Z</modified>
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.Language != "fr-ca" {
t.Errorf("Incorrect language, got: %q", feed.Language)
}
if len(feed.Entries) != 2 {
t.Fatalf("Expected 2 entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Language != "pt-br" {
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
}
if feed.Entries[1].Language != "fr-ca" {
t.Errorf("Expected entry to inherit feed language, got: %q", feed.Entries[1].Language)
}
}
+12
View File
@@ -22,6 +22,12 @@ import (
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// Language is the natural language of the feed, declared by an
// xml:lang attribute on the atom:feed element. The tag is
// namespace-qualified so that lang attributes from other namespaces
// cannot override the real xml:lang value.
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
@@ -96,6 +102,12 @@ type atom10Entry struct {
// atom:entry elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// Language is the natural language of the entry, declared by an
// xml:lang attribute on the atom:entry element. The tag is
// namespace-qualified so that lang attributes from other namespaces
// cannot override the real xml:lang value.
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
+121 -83
View File
@@ -5,8 +5,6 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -14,6 +12,7 @@ import (
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/language"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
@@ -22,12 +21,11 @@ type atom10Adapter struct {
atomFeed *atom10Feed
}
func NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {
return &atom10Adapter{atomFeed}
}
func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
func (a *atom10Adapter) buildFeed(baseURL string) *model.Feed {
feed := &model.Feed{
FeedURL: baseURL,
SiteURL: baseURL,
}
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
@@ -35,8 +33,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
@@ -45,8 +41,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
@@ -58,16 +52,20 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
// Populate the feed description.
feed.Description = a.atomFeed.Subtitle.body()
feed.Language = language.Normalize(a.atomFeed.Language)
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
feed.IconURL = absoluteIconURL
for _, value := range []string{a.atomFeed.Icon, a.atomFeed.Logo} {
if value = strings.TrimSpace(value); value == "" {
continue
}
} else if a.atomFeed.Logo != "" {
if absoluteLogoURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
feed.IconURL = absoluteLogoURL
if iconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, value); err == nil {
feed.IconURL = iconURL
break
}
}
feed.Entries = a.populateEntries(feed.SiteURL)
return feed
}
@@ -86,6 +84,16 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
}
}
// If the entry has no links, attempt to use its ID as a URL
// and if that fails, use the site URL.
if entry.URL == "" {
if urllib.IsAbsoluteURL(atomEntry.ID) {
entry.URL = atomEntry.ID
} else {
entry.URL = siteURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.body()
if entry.Content == "" {
@@ -104,44 +112,52 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
}
}
// Populate the entry language. xml:lang applies to the whole
// subtree it is declared on, so an entry without its own
// xml:lang inherits the feed-level value.
entry.Language = language.Normalize(atomEntry.Language)
if entry.Language == "" {
entry.Language = language.Normalize(a.atomFeed.Language)
}
// Populate the entry author.
authors := atomEntry.Authors.personNames()
if len(authors) == 0 {
authors = a.atomFeed.Authors.personNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
entry.Author = strings.Join(authors, ", ")
// Populate the entry date.
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
if value != "" {
if parsedDate, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = parsedDate
break
}
if value = strings.TrimSpace(value); value == "" {
continue
}
parsedDate, err := date.Parse(value)
if err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
continue
}
entry.Date = parsedDate
break
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate categories.
categories := atomEntry.Categories.CategoryNames()
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
entry.Tags = atomEntry.Categories.CategoryNames()
if len(entry.Tags) == 0 {
entry.Tags = a.atomFeed.Categories.CategoryNames()
}
// Sort and deduplicate categories.
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
// Populate the commentsURL if defined.
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
@@ -167,22 +183,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
if _, found := uniqueEnclosuresMap[mediaURL]; found {
continue
}
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
@@ -193,17 +215,21 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
continue
}
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; found {
continue
}
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
for _, mediaContent := range atomEntry.AllMediaContents() {
@@ -211,22 +237,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
continue
}
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
@@ -234,22 +266,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
if err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
continue
}
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
continue
}
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
entries = append(entries, entry)
+205
View File
@@ -1837,3 +1837,208 @@ func TestParseFeedWithIconURL(t *testing.T) {
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
}
}
func TestParseEntryWithIDAsURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<link href="http://example.org/atom" rel="self"/>
<entry>
<id>http://www.example.org/entries/1</id>
</entry>
<entry>
<id>mailto:john.doe@example.org</id>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[1].URL != "http://example.org/" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[1].URL)
}
}
func TestParseFeedWithLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr-CA">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Language != "fr-ca" {
t.Errorf("Incorrect language, got: %q", feed.Language)
}
}
func TestParseFeedWithoutLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Language != "" {
t.Errorf("Expected empty language, got: %q", feed.Language)
}
}
func TestParseEntryWithLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry xml:lang="fr-CA">
<title>Bonjour</title>
<link href="http://example.org/2003/12/13/bonjour"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
</entry>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
}
if feed.Entries[0].Language != "fr-ca" {
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
}
}
func TestParseEntryWithoutLanguageInheritsFeedLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Hello</title>
<link href="http://example.org/2003/12/13/hello"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
</entry>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
}
if feed.Entries[0].Language != "en" {
t.Errorf("Expected entry to inherit feed language, got: %q", feed.Entries[0].Language)
}
}
func TestParseEntryWithoutAnyLanguage(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Hello</title>
<link href="http://example.org/2003/12/13/hello"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
</entry>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
}
if feed.Entries[0].Language != "" {
t.Errorf("Expected empty entry language, got: %q", feed.Entries[0].Language)
}
}
func TestParseFeedWithForeignLangAttribute(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:foo="http://example.org/ns" xml:lang="fr-CA" foo:lang="zz">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry xml:lang="fr-CA" foo:lang="zz">
<title>Bonjour</title>
<link href="http://example.org/2003/12/13/bonjour"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
</entry>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Language != "fr-ca" {
t.Errorf("Incorrect language, got: %q", feed.Language)
}
if len(feed.Entries) != 1 {
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
}
if feed.Entries[0].Language != "fr-ca" {
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
}
}
func TestParseFeedWithUnqualifiedLangAttribute(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" lang="de">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
</feed>`
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
if err != nil {
t.Fatal(err)
}
if feed.Language != "" {
t.Errorf("Expected empty language for unqualified lang attribute, got: %q", feed.Language)
}
}
+44 -29
View File
@@ -4,6 +4,8 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"cmp"
"slices"
"strings"
)
@@ -32,19 +34,9 @@ func (a *AtomPerson) PersonName() string {
type atomPersons []*AtomPerson
// personNames returns sorted and deduplicated author names.
func (a atomPersons) personNames() []string {
names := make([]string, 0, len(a))
authorNamesMap := make(map[string]bool, len(a))
for _, person := range a {
personName := person.PersonName()
if _, ok := authorNamesMap[personName]; !ok {
names = append(names, personName)
authorNamesMap[personName] = true
}
}
return names
return makeSorted((*AtomPerson).PersonName, a)
}
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
@@ -97,7 +89,7 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
}
func (a atomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
var links []*AtomLink
links := make([]*AtomLink, 0, len(a))
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
@@ -134,22 +126,45 @@ type atomCategory struct {
Label string `xml:"label,attr"`
}
type atomCategories []atomCategory
func (ac atomCategories) CategoryNames() []string {
var categories []string
for _, category := range ac {
label := strings.TrimSpace(category.Label)
if label != "" {
categories = append(categories, label)
} else {
term := strings.TrimSpace(category.Term)
if term != "" {
categories = append(categories, term)
}
}
func (ac atomCategory) name() string {
name := strings.TrimSpace(ac.Label)
if name != "" {
return name
}
return categories
name = strings.TrimSpace(ac.Term)
if name != "" {
return name
}
return ""
}
type atomCategories []atomCategory
// CategoryNames returns sorted and deduplicated category names.
func (ac atomCategories) CategoryNames() []string {
return makeSorted(atomCategory.name, ac)
}
func makeSorted[I any, O cmp.Ordered](fn func(I) O, values []I) []O {
var zero O
sorted := make([]O, 0, len(values))
for _, in := range values {
out := fn(in)
if out == zero {
continue
}
where, found := slices.BinarySearch(sorted, out)
if found {
continue
}
// Insert sorted to avoid duplicates.
sorted = slices.Insert(sorted, where, out)
}
return sorted
}
+1 -1
View File
@@ -27,6 +27,6 @@ func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error)
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
}
adapter := &atom10Adapter{atomFeed}
return adapter.BuildFeed(baseURL), nil
return adapter.buildFeed(baseURL), nil
}
}
+4 -2
View File
@@ -312,6 +312,8 @@ var replacer = strings.NewReplacer(
"Thurs,", "Thu,",
"Thur,", "Thu,",
)
var losAngelesLocation, _ = time.LoadLocation("America/Los_Angeles")
var newYorkLocation, _ = time.LoadLocation("America/New_York")
// Parse parses a given date string using a large
// list of commonly found feed date formats.
@@ -352,9 +354,9 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Workaround for dates that don't use GMT.
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
loc, _ = time.LoadLocation("America/Los_Angeles")
loc = losAngelesLocation
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
loc, _ = time.LoadLocation("America/New_York")
loc = newYorkLocation
}
return time.ParseInLocation(layout, ds, loc)
+7 -5
View File
@@ -4,12 +4,14 @@
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
type DublinCoreChannelElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreLanguage string `xml:"http://purl.org/dc/elements/1.1/ language"`
}
type DublinCoreItemElement struct {
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
DublinCoreLanguage string `xml:"http://purl.org/dc/elements/1.1/ language"`
}
@@ -53,6 +53,15 @@ func NewRequestBuilder() *RequestBuilder {
}
}
// Clone returns an independent copy of the builder. Mutating the copy (for
// example to disable redirects for a single request) leaves the original
// untouched.
func (r *RequestBuilder) Clone() *RequestBuilder {
clone := *r
clone.headers = r.headers.Clone()
return &clone
}
func (r *RequestBuilder) WithHeader(key, value string) *RequestBuilder {
r.headers.Set(key, value)
return r
@@ -267,6 +267,29 @@ func TestRequestBuilder_WithoutRedirects(t *testing.T) {
}
}
func TestRequestBuilder_Clone(t *testing.T) {
original := NewRequestBuilder().WithHeader("X-Shared", "value")
clone := original.Clone().WithoutRedirects()
clone.WithHeader("X-Clone-Only", "value")
if original.withoutRedirects {
t.Error("Mutating the clone should not disable redirects on the original")
}
if original.headers.Get("X-Clone-Only") != "" {
t.Error("Mutating the clone's headers should not affect the original")
}
if clone.headers.Get("X-Shared") != "value" {
t.Error("Expected the clone to inherit the original headers")
}
if clone.clientTimeout != original.clientTimeout {
t.Error("Expected the clone to inherit the original timeout")
}
}
func TestRequestBuilder_DisableHTTP2(t *testing.T) {
builder := NewRequestBuilder()
builder = builder.DisableHTTP2(true)
@@ -540,25 +563,25 @@ func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
}
func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
// Create a slow server
// Create a slow server that blocks until the client disconnects, so
// server.Close() does not have to wait for a fixed sleep to elapse.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
<-r.Context().Done()
}))
defer server.Close()
builder := NewRequestBuilder()
start := time.Now()
_, err := builder.WithTimeout(1 * time.Second).ExecuteRequest(server.URL)
_, err := builder.WithTimeout(100 * time.Millisecond).ExecuteRequest(server.URL)
duration := time.Since(start)
if err == nil {
t.Error("Expected timeout error")
}
// Should timeout around 1 second, allow some margin
if duration > 1500*time.Millisecond {
t.Errorf("Expected timeout around 1s, took %v", duration)
// Should timeout around 100ms, allow some margin
if duration > 500*time.Millisecond {
t.Errorf("Expected timeout around 100ms, took %v", duration)
}
}
+51 -24
View File
@@ -29,6 +29,8 @@ import (
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"miniflux.app/v2/internal/model"
@@ -41,6 +43,34 @@ type filterRule struct {
type filterRules []filterRule
const maxCachedRegexes = 1024
var (
compiledRegexesCache sync.Map
compiledRegexesCacheSize atomic.Int64
)
func cachedRegex(pattern string) *regexp.Regexp {
if v, ok := compiledRegexesCache.Load(pattern); ok {
return v.(*regexp.Regexp)
}
re, err := regexp.Compile(pattern)
if err != nil {
slog.Warn("Failed on regexp compilation",
slog.String("regex_pattern", pattern),
slog.Any("error", err),
)
}
compiledRegexesCache.Store(pattern, re)
if compiledRegexesCacheSize.Add(1) >= maxCachedRegexes {
compiledRegexesCache.Clear()
compiledRegexesCacheSize.Store(0)
}
return re
}
func ParseRules(userRules, feedRules string) filterRules {
rules := make(filterRules, 0)
for line := range strings.SplitSeq(strings.TrimSpace(userRules), "\n") {
@@ -103,12 +133,8 @@ func matchesEntryRegexRules(regexPattern string, feed *model.Feed, entry *model.
return false, true // No pattern means rule is valid but doesn't match
}
compiledRegex, err := regexp.Compile(regexPattern)
if err != nil {
slog.Warn("Failed on regexp compilation",
slog.String("regex_pattern", regexPattern),
slog.Any("error", err),
)
compiledRegex := cachedRegex(regexPattern)
if compiledRegex == nil {
return false, false // Invalid regex pattern
}
@@ -151,26 +177,28 @@ func matchesEntryFilterRules(rules filterRules, feed *model.Feed, entry *model.E
}
func matchesRule(rule filterRule, entry *model.Entry) bool {
switch rule.Type {
case "EntryDate":
if rule.Type == "EntryDate" {
return isDateMatchingPattern(rule.Value, entry.Date)
}
re := cachedRegex(rule.Value)
if re == nil {
return false
}
switch rule.Type {
case "EntryTitle":
match, _ := regexp.MatchString(rule.Value, entry.Title)
return match
return re.MatchString(entry.Title)
case "EntryURL":
match, _ := regexp.MatchString(rule.Value, entry.URL)
return match
return re.MatchString(entry.URL)
case "EntryCommentsURL":
match, _ := regexp.MatchString(rule.Value, entry.CommentsURL)
return match
return re.MatchString(entry.CommentsURL)
case "EntryContent":
match, _ := regexp.MatchString(rule.Value, entry.Content)
return match
return re.MatchString(entry.Content)
case "EntryAuthor":
match, _ := regexp.MatchString(rule.Value, entry.Author)
return match
return re.MatchString(entry.Author)
case "EntryTag":
return containsRegexPattern(rule.Value, entry.Tags)
return slices.ContainsFunc(entry.Tags, re.MatchString)
}
return false
@@ -227,12 +255,11 @@ func isDateMatchingPattern(pattern string, entryDate time.Time) bool {
}
func containsRegexPattern(pattern string, items []string) bool {
for _, item := range items {
if matched, _ := regexp.MatchString(pattern, item); matched {
return true
}
re := cachedRegex(pattern)
if re == nil {
return false
}
return false
return slices.ContainsFunc(items, re.MatchString)
}
func parseDuration(duration string) (time.Duration, error) {
+27 -37
View File
@@ -95,18 +95,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
slog.String("feed_url", subscription.FeedURL),
)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feedCreationRequest.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
@@ -124,17 +112,17 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(feedCreationRequest.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password).
WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(feedCreationRequest.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(feedCreationRequest.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy).
IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates).
DisableHTTP2(feedCreationRequest.DisableHTTP2)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))
defer responseHandler.Close()
@@ -179,6 +167,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.BlockFilterEntryRules = feedCreationRequest.BlockFilterEntryRules
subscription.KeepFilterEntryRules = feedCreationRequest.KeepFilterEntryRules
subscription.HideGlobally = feedCreationRequest.HideGlobally
subscription.NoMediaPlayer = feedCreationRequest.NoMediaPlayer
subscription.EtagHeader = responseHandler.ETag()
subscription.LastModifiedHeader = responseHandler.LastModified()
subscription.FeedURL = responseHandler.EffectiveURL()
@@ -232,22 +221,23 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(originalFeed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(originalFeed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(originalFeed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUsernameAndPassword(originalFeed.Username, originalFeed.Password).
WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(originalFeed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(originalFeed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(originalFeed.FetchViaProxy).
IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates).
DisableHTTP2(originalFeed.DisableHTTP2)
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
if !ignoreHTTPCache {
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
requestBuilder = requestBuilder.
WithETag(originalFeed.EtagHeader).
WithLastModified(originalFeed.LastModifiedHeader)
}
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
@@ -351,7 +341,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.EtagHeader = responseHandler.ETag()
originalFeed.LastModifiedHeader = responseHandler.LastModified()
originalFeed.Language = updatedFeed.Language
originalFeed.IconURL = updatedFeed.IconURL
iconChecker := icon.NewIconChecker(store, originalFeed)
if forceRefresh {
+10 -10
View File
@@ -26,16 +26,16 @@ func NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {
}
func (c *iconChecker) UpdateOrCreateFeedIcon() {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(c.feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(c.feed.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(c.feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
requestBuilder := fetcher.NewRequestBuilder().
WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent()).
WithCookie(c.feed.Cookie).
WithTimeout(config.Opts.HTTPClientTimeout()).
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
WithCustomFeedProxyURL(c.feed.ProxyURL).
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
UseCustomApplicationProxyURL(c.feed.FetchViaProxy).
IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates).
DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := newIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
if icon, err := iconFinder.findIcon(); err != nil {

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