Compare commits

...

525 Commits

Author SHA1 Message Date
Frédéric Guillot 6439d352d2 docs(man): improve miniflux.1 consistency and fix typos 2026-01-05 18:55:33 -08:00
Frédéric Guillot a33cfb2b7d feat(locale): update translations 2026-01-05 17:58:19 -08:00
Frédéric Guillot 590adb9967 refactor(googlereader): remove output param check for user-info handler
The `output` parameter seems to be optional and Miniflux will always
returns a JSON response. This change makes Miniflux more consistent with
other open source RSS readers.
2026-01-05 16:01:01 -08:00
Frédéric Guillot 9e817e646f feat(googlereader)!: remove CORS handler
The Google Reader API is not supposed to be used by web clients.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 18:10:02 -07:00
Kelly Norton 385d8bb969 feat(client): allow http.Client as option and add context to api methods 2025-10-18 12:45:00 -07:00
jvoisin c171da1734 refactor(reader): minor date parsing simplifications
- There is no need to use two replacers instead of one. It might help keep the
  input in a low CPU cache.
- Don't cast an int to float (twice) for nothing in checkTimezoneRange.
2025-10-16 20:26:18 -07:00
dependabot[bot] 11ea137027 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.3 to 2.24.4
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.3 to 2.24.4.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.3...v2.24.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-16 20:14:43 -07:00
jvoisin 0a15075c00 refactor(sanitizer): save some cycles in hasRequiredAttributes
There is no need to iterate twice on the attributes of source and img tags,
when we can do it once. This shouldn't bring any noticeable performances
improvements, except maybe in some extreme cases on pages with a lot of images
with a lot of attributes.
2025-10-14 20:03:47 -07:00
Christian Frommert b1fda599ac feat(integration): add tags option for karakeep integration 2025-10-13 16:03:33 -07:00
dependabot[bot] 509b7682ad build(deps): bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [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/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 15:20:11 -07:00
Julien Voisin 06c2e50ffa refactor(ui): use native <dialog> element instead of custom modal implementation
This simplifies the client-side code and improves accessibility.
2025-10-12 15:31:05 -07:00
Frédéric Guillot ec93656ef5 perf: preallocate slices 2025-10-10 20:28:13 -07:00
Julien Voisin 317aaeeec7 perf: replace from ParseInt/ParseFloat with faster Itoa
There is no need to use strconv.ParseInt and strconv.ParseFloat instead of
the much simpler/faster strconv.Itoa.
2025-10-10 20:27:26 -07:00
jvoisin d862f79123 refactor(fever): explicitly size slices 2025-10-10 20:14:12 -07:00
dependabot[bot] 1bea41b19c build(deps): bump golang.org/x/oauth2 from 0.31.0 to 0.32.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.31.0...v0.32.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 19:26:12 -07:00
dependabot[bot] 65a39096e7 build(deps): bump golang.org/x/image from 0.31.0 to 0.32.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/image/compare/v0.31.0...v0.32.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 19:22:26 -07:00
dependabot[bot] 131dc674e4 build(deps): bump golang.org/x/net from 0.45.0 to 0.46.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/net/compare/v0.45.0...v0.46.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 19:10:16 -07:00
dependabot[bot] b0dced42c0 build(deps): bump golang.org/x/net from 0.44.0 to 0.45.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/net/compare/v0.44.0...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-07 19:08:50 -07:00
Frédéric Guillot a34e33b5c5 chore: update make lint command 2025-10-06 18:26:53 -07:00
Frédéric Guillot af12fe309e ci: enable perfsprint and goheader Go linters 2025-10-06 18:25:00 -07:00
jvoisin dd44fbcc76 refactor(misc): replace fmt.Errorf with errors.New where possible
No need to to invoke the whole Printf machinery for constant strings. While
this shouldn't have an impact on memory consumption nor allocation (as
constructing errors to return is never in a hot path), this should reduce a bit
the code size, as errors.New will be inlined to a simple struct initialization
instead of a function call.
2025-10-06 17:54:04 -07:00
dependabot[bot] 9c956d1b0d build(deps): bump github.com/coreos/go-oidc/v3 from 3.15.0 to 3.16.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.15.0 to 3.16.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.15.0...v3.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-03 17:00:08 -07:00
Julien Voisin bf7f55e28a feat(template): extract CSP to a function, systematically use nonces, and use default-src 'none' instead of self
Having the CSP built in a function instead of in the template makes it easier
to properly construct it. This was also the opportunity to switch from
default-src 'self' to default-src 'none', to deny everything that isn't
explicitly allowed, instead of allowing everything coming from 'self'.

Moreover, as Miniflux is shoving the content of feeds in the same origin as
itself, using self doesn't do much security-wise. It's much better to
systematically use a nonce-based policy, so that an attacker able to bypass the
sanitization will have to guess the nonce to gain arbitrary javascript
execution.
2025-10-02 19:38:37 -07:00
Axel Verhaeghe b8bc367a00 feat(rewrite): add remove_img_blur_params rule
Adds a new content rewrite rule to strip image URL query parameters from blurred images.

This addresses issues with sites like Belgian national news that use blurry placeholder images which get replaced with high-quality versions, allowing Miniflux to fetch the original images instead of the placeholders.
2025-10-01 20:41:08 -07:00
Frédéric Guillot 04a360a536 test(xml): add test cases regarding XML encoding 2025-09-30 20:44:36 -07:00
Julien Voisin fac18d5c57 refactor(xml): change getEncoding to return []byte and move CharsetReader callback to a separate function
- There is no need for getEncoding to return a string instead of an array of
  bytes, so let's make it return a []byte instead of a string.
- There is no reason why the function used for decoder.CharsetReader
  has to be defined as a lambda instead of a proper function. One might argue
  the other way around, but a lambda is living on the heap, while a "real"
  function doesn't.
2025-09-30 18:04:42 -07:00
jvoisin a3d1ecc58a refactor(database): get rid of the dependency on hstore
The hstore extension was briefly used at some point by miniflux, but not
anymore. Yet it's still required to deploy miniflux, as a hstore column is
created then destroyed during the database creation/migration. This commit
refactor the migrations (scary!) to get rid of hstore, so that it doesn't need
to be installed/present when deploying/running miniflux.

This should close #3759
2025-09-30 17:41:56 -07:00
Frédéric Guillot 8adcaed29e docs: clarify POLLING_FREQUENCY documentation 2025-09-29 21:22:59 -07:00
Julien Voisin 5a97bf8b5e refactor(sanitizer): simplify hasValidURIScheme and isBlockedResource functions
- use an array instead of a map for the schemes, as the overwhelming majority
  of them will be either http or https, which we can place in front of the
  array. This is faster than using a map.
- Simplify hasValidURIScheme by using strings.HasPrefix instead of doing
  strings.IndexByte
- Simplify isBlockedResource by using a simple for loop, instead of a weird
  slices.ContainsFunc+strings.Contains construct.

On my noisy system:

```
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/sanitizer
           │   old.txt   │            new.txt            │
           │   sec/op    │   sec/op     vs base          │
Sanitize-8   22.19m ± 4%   21.97m ± 4%  ~ (p=0.948 n=50)
```
2025-09-29 19:42:45 -07:00
Frédéric Guillot e279b955c4 fix(css): avoid layout overflow when external link is too long 2025-09-28 13:34:18 -07:00
Julien Voisin 1620f8d3f2 refactor(database): remove implicit not null constraint for serial types
- The (big)serial keyword is already `not null`, so no need to it explicitly.
  See https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-SERIAL
2025-09-28 13:13:54 -07:00
Devon 1a29c1568c feat(ui): redirect back to original page after logging in 2025-09-26 20:20:34 -07:00
Frédéric Guillot ff07f02716 fix(jsonfeed): fallback to external_url when url is missing 2025-09-26 20:05:39 -07:00
jvoisin 79b0d0b9cc feat(integration): add integration with archive.org
Tested locally:

```console
$ Tue 26 Aug 17:34:05 CEST 2025
$ go build && ./miniflux.app -c ./config.ini  -debug
level=DEBUG msg="Starting daemon..."
level=DEBUG msg="Starting background scheduler..."
level=DEBUG msg="Worker started" worker_id=15
level=DEBUG msg="Worker started" worker_id=0

[…]

level=DEBUG msg="Incoming request" client_ip=127.0.0.1 request.method=POST request.uri=/entry/save/29773 request.protocol=HTTP/1.1 request.execution_time=5.57385ms
level=DEBUG msg="Sending entry to archive.org" user_id=1 entry_id=29773 entry_url=https://sumnerevans.com/portfolio/
level=DEBUG msg="Sending entry to archive.org" title=Portfolio url=https://sumnerevans.com/portfolio/
^C
$ curl -I -H "User-Agent: Mozilla"  https://web.archive.org/web/20250826153413/https://sumnerevans.com/portfolio/ | grep orig-date
x-archive-orig-date: Tue, 26 Aug 2025 15:34:13 GMT
$
```
2025-09-26 19:46:12 -07:00
Julien Voisin 5fa0709663 feat(rewrite): add add_image_title rule for explainxkcd.com 2025-09-25 17:32:22 -07:00
jakubp a7fa2ecc8c fix(scraper): update Dark Reading scraper rule 2025-09-23 19:52:17 -07:00
dependabot[bot] 10b2b36895 build(deps): bump github.com/go-webauthn/webauthn from 0.13.4 to 0.14.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.4 to 0.14.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.4...v0.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-15 16:57:47 -07:00
Frédéric Guillot 87e65f800e feat(locale): update French translation 2025-09-14 11:15:52 -07:00
Frédéric Guillot 907cf16af1 fix(fever): fix typo in variable name 2025-09-14 11:11:41 -07:00
Cthulhux 854a78a7d1 feat(locale): update German translation 2025-09-14 10:51:32 -07:00
Frédéric Guillot 5e607be86a refactor(config): rewrite config parser
This PR refactors the configuration parser, replacing the old parser implementation with a new, more structured approach that includes validation and improved organization.

Key changes:
- Complete rewrite of the configuration parser using a map-based structure with built-in validation
- Addition of comprehensive validator functions for configuration values
- Renamed numerous configuration getter methods for better consistency
2025-09-14 10:51:04 -07:00
Matthaiks 502e7108dd feat(locale): update Polish translation 2025-09-12 18:00:58 -07:00
Julien Voisin 0b93d8abcc refactor(subscription): combine findSubscriptionsFromYouTubeChannelPage and findSubscriptionsFromYouTubePlaylistPage functions 2025-09-12 17:59:41 -07:00
Julien Voisin 93a8629910 refactor(js): simplify modal_handler.js 2025-09-12 17:24:51 -07:00
Frédéric Guillot b1742168e1 fix(timezone): make sure legacy time zones are no longer used
Debian Trixie has removed several time zones. This change makes sure only the current IANA time zones are in use.
2025-09-12 16:20:27 -07:00
Steven vanZyl 4eff9129ab feat(ui): add "back to top" link 2025-09-12 13:41:39 -07:00
Cthulhux eb22d90b56 feat(locale): update German translation 2025-09-10 18:26:23 -07:00
Julien Voisin 8f2dd02f3f refactor(subscription): combine all JSON feed mime types in one query
- Look for JSON feeds in one pass instead of two
- Move conditions around to reduce the amount of comparisons
- Edit an existing test to exercise this commit's changes
2025-09-10 16:50:40 -07:00
Matthaiks da8d4d86c3 feat(locale): update Polish translation 2025-09-10 16:28:21 -07:00
Frédéric Guillot 7ada5d54be fix(icon): implement better handling of relative icon URLs within a subfolder 2025-09-09 20:18:50 -07:00
Kevin Sicong Jiang 8129500296 feat(integration): add support for Wallabag tags 2025-09-09 17:47:51 -07:00
dependabot[bot] f1143151c5 build(deps): bump golang.org/x/net from 0.43.0 to 0.44.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/net/compare/v0.43.0...v0.44.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 16:16:09 -07:00
dependabot[bot] baf8e40152 build(deps): bump golang.org/x/image from 0.30.0 to 0.31.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/image/compare/v0.30.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:59:40 -07:00
dependabot[bot] a98374b81f build(deps): bump golang.org/x/crypto from 0.41.0 to 0.42.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.41.0 to 0.42.0.
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:50:16 -07:00
dependabot[bot] 77e0b07f9f build(deps): bump golang.org/x/term from 0.34.0 to 0.35.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/term/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:42:29 -07:00
Julien Voisin 645800ce3c refactor(js): remove isTouchSupported() static function 2025-09-08 15:41:50 -07:00
dependabot[bot] a60a153003 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.0 to 2.24.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.0 to 2.24.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.0...v2.24.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:36:47 -07:00
dependabot[bot] 7d40e2993b build(deps): bump golang.org/x/oauth2 from 0.30.0 to 0.31.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.30.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:36:03 -07:00
dependabot[bot] 91a625c559 build(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:35:34 -07:00
dependabot[bot] dbb3855901 build(deps): bump actions/setup-go from 5 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:35:06 -07:00
Julien Voisin 3051acf369 perf(xml): eliminate bound checks in filterValidXMLChars
Optimizes the filterValidXMLChars function by changing the loop variable type from int to uint to eliminate bound checks during compilation, resulting in a ~4% performance improvement.

- Changes loop variable i from int to uint to remove compiler-generated bound checks
- Adjusts type conversions accordingly to maintain correctness

```
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/parser
        │   old.txt   │              new.txt               │
        │   sec/op    │   sec/op     vs base               │
Parse-8   40.91m ± 3%   39.30m ± 2%  -3.94% (p=0.000 n=50)
```
2025-09-08 15:33:55 -07:00
Frédéric Guillot 5f38054965 fix(ui): incorrect force refresh interval unit used in messages
Regression introduced in commit c6536e8
2025-09-08 12:12:11 -07:00
Julien Voisin fa361ab1ce perf: convert some slices to array 2025-09-08 12:11:18 -07:00
jvoisin afe80e6bae refactor(proxy): remove usage of fmt
There is no need to use the heavy machinery (fmt) when we can simply use
string concatenation instead.
2025-09-08 12:00:59 -07:00
Julien Voisin f2976bff5d refactor: remove model.UserSessions struct 2025-09-08 11:56:43 -07:00
Julien Voisin 84078c7c20 refactor: avoid unnecessary usage of Printf 2025-09-08 11:54:16 -07:00
dependabot[bot] d70817e441 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.0 to 1.23.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.0...v1.23.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.23.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 11:42:23 -07:00
Frédéric Guillot e8f5c2446c fix(config): FORCE_REFRESH_INTERVAL duration should be in minutes
Regression introduced in commit c6536e8
2025-08-25 15:46:10 -07:00
Matthaiks 737f25f441 feat(locale): update Polish translation 2025-08-24 13:04:25 -07:00
Frédéric Guillot f83d66769d fix(api): do not return removed entries
Since Miniflux 2.2.12, the content of removed entries is cleared.
2025-08-23 15:17:37 -07:00
jvoisin af149e46df Revert "refactor(storage): simplify feed.go by using min(), inline errors, and use idiomatic conditions"
This reverts commit b1cbaae71c.
2025-08-22 12:51:54 -07:00
jvoisin 4f252b33c9 refactor(template): rename noescape to safeHTML
This makes the code more consistent, since all the other escaping escape
hatches have a `safe` prefix.
2025-08-22 12:51:21 -07:00
jvoisin da9c3a4032 feat(js): tighten the trusted types policy
- Implement a better/simpler polyfill for web browsers that don't supported
  trusted types yet
- Use two separate policies: one to create HTML, another to create/use script
  urls
- Instead of having the policy live in the top-level scope, they're now
  declared at the lowest possible scope, right before they're used, making them
  inaccessible outside of it. This puts their usage completely out of reach of
  an attacker unable to gain some control outside of those two (small) scopes,
  and thus removes the need to tighten the policies.
- Remove the now-unused tt.js file

This has been tested on Firefox (doesn't support trusted types) and on Chromium
(does support trusted types).
2025-08-21 19:41:45 -07:00
Benedikt Hopmann 8e1d0bb693 fix(locale): Update de_DE translation to match changes from 'bookmark' to 'starred'
Updated strings to match changes from 'bookmark' to 'starred'. Also renamed some strings from 'Einträge' to 'Artikel' for more consistency.
2025-08-21 16:59:54 -07:00
Steven vanZyl 60cd7ffe88 refactor: Replace "Bookmarks" with "Starred"
Replaces usage of the word "bookmark" with "star"/"starred" in order to be more
consistent with the UI and database models, and to reduce confusion with
"bookmarklet" and integration features.

This is in preparation of future work on read-it-later features.
Which are also not called "bookmarks" to prevent any further confusion.
https://github.com/orgs/miniflux/discussions/3719

Related-to: https://github.com/miniflux/v2/pull/2219
2025-08-20 20:49:45 -07:00
Peter Sanchez 4d656d2739 feat(integration): add LinkTaco service for saving articles 2025-08-20 20:35:33 -07:00
gudvinr 983291c78b refactor(cli): use time.Duration for cleanup tasks 2025-08-20 19:45:24 -07:00
gudvinr 7060ecc163 refactor(cli): use time.Duration for scheduler frequency
Polling frequency is undocumented so it's not exacly clear what units were.
2025-08-20 19:45:24 -07:00
gudvinr 4af12a4129 refactor(metric): use time.Duration for refresh duration 2025-08-20 19:45:24 -07:00
gudvinr c6536e8d90 refactor(http): use time.Duration for refresh interval
It's not clear which units of time used for refresh interval.
Convert to time.Duration for clarity.
2025-08-20 19:45:24 -07:00
gudvinr 30453ad7ec refactor(fetcher): use time.Duration for client timeout values
All functions use time.Duration, so instead of converting everywhere, do it once.
2025-08-20 19:45:24 -07:00
gudvinr 71af68becd refactor(server): use time.Duration for timeout values
Instead of converting at the very last moment,
it's simpler and more readable to use time.Duration ASAP.
2025-08-20 19:45:24 -07:00
gudvinr ed3bf59356 refactor(reader): use time.Duration instead of minutes count
In general, duration is used as time unit representation.

At some places when int is returned, there's no documentation which unit is used.

So just convert to time.Duration ASAP.
2025-08-20 19:45:24 -07:00
gudvinr 03021af53c feat(config): time interval parser
Simplifies handling of time intervals in config values.
2025-08-20 19:45:24 -07:00
gudvinr 83254a1f26 refactor(model): replace Sprintf("%d") in tests
This is just Itoa but weird.
2025-08-20 19:45:24 -07:00
Julien Voisin 3acb888309 refactor(response): simplify switch-case and remove unnecessary defer
- b.body can never be of type error, so let's remove it from the switch-case
  construct.
- there is no need to use defer when the only return statement is two lines
  after.
2025-08-20 19:31:28 -07:00
Julien Voisin 7c29166ef9 refactor(ui): move inline SVG to sprite.svg
There is no need to have an inline svg when we can have it in the sprite.svg
file.
2025-08-20 19:26:47 -07:00
Julien Voisin 71105bec56 refactor(template): improve consistency in layout.html (meta tags, whitespace, comments)
- Group the `<meta>` tags together
- Trim some superfluous whitespace
- Remove an obvious and thus useless comment
2025-08-20 19:24:13 -07:00
Julien Voisin 2a372674a2 refactor(icon): avoid calling AbsoluteURL twice for the feed icon URL
The url of a feed's icon is always absolute.
2025-08-20 19:01:59 -07:00
Julien Voisin 9d32b23ab0 perf(sanitizer): make sanitizer ~10% faster by using slices.Contains instead of nested maps
```console
$ go test -bench=. -count=25 > old.txt
$ go test -bench=. -count=25 > new.txt
$ benchstat old.txt new.txt
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/sanitizer
           │   old.txt   │            new.txt            │
           │   sec/op    │   sec/op     vs base          │
Sanitize-8   21.55m ± 5%   19.64m ± 9%  ~ (p=0.059 n=25)
```

Almost a 10% improvement, yay.
2025-08-20 18:54:49 -07:00
jvoisin da8bf3890c refactor(templates): be explicit about dependencies
Instead of blindly compiling all the common/ templates for every view/ ones,
let's be explicit about the dependencies. This should significantly decrease
the resident memory consumption, as ParseTemplate is responsible for ~10M of
the current 11M of heap memory on my instance, so any win there is interesting.
This will also allow better factorization of templates, now that everything is
explicit. Another side-effect is that it'll make testing easier, as we now have
a comprehensive list of views/ templates affected by a change in a file in
common/
2025-08-20 18:51:51 -07:00
dependabot[bot] b30eac4ebe build(deps): bump github.com/tdewolff/minify/v2 from 2.23.11 to 2.24.0
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.11 to 2.24.0.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.11...v2.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 17:52:22 -07:00
Frédéric Guillot 459b1bf1ee test(icon): add tests for finding icon URLs in HTML documents 2025-08-19 20:14:34 -07:00
Frédéric Guillot fdc12bf18c fix(icon): improve logging messages in resizeIcon function 2025-08-19 20:14:34 -07:00
Frédéric Guillot 1b1ceaf8b4 test(icon): add test case to verify SVG minification with error 2025-08-19 20:14:34 -07:00
jvoisin cad40cc158 feat(icon): minify svg favicons 2025-08-19 20:14:34 -07:00
Frédéric Guillot d0e43f8682 docs: fix typos in the man page 2025-08-19 19:26:50 -07:00
Frédéric Guillot c105b14f58 test(api): add integration test for fetching categories with counters 2025-08-18 20:08:31 -07:00
Frédéric Guillot 49575c8902 test(version): add a test to enforce the version format 2025-08-18 19:51:09 -07:00
Frédéric Guillot b25f9651fe test(fetcher): add unit tests for RequestBuilder 2025-08-18 19:38:14 -07:00
Frédéric Guillot 953ea885e0 fix(mediaproxy): always forward the browser Accept header
Tumblr CDN is blocking the Accept header used to fetch feeds
2025-08-18 18:08:21 -07:00
Frédéric Guillot 9536ce7dbc fix(version): change development version naming to avoid breaking some clients
Some Miniflux clients expect a specific version format.
For example, Flux News converts the string version to an integer.

Using `Development Version` will break some clients.
2025-08-18 17:41:17 -07:00
Frédéric Guillot f6532e16f2 docs: update incorrect default value for DATABASE_MIN_CONNS in the man page 2025-08-18 17:21:16 -07:00
Frédéric Guillot 84ae1d5dc0 fix(storage): index only the first 500K characters of the article contents to avoid tsvector limits
The length of a tsvector (lexemes + positions) must be less than 1 megabyte.

We don't need to index the entire content, and we need to keep a buffer for the positions.
2025-08-17 19:32:56 -07:00
Frédéric Guillot 5403ca09f6 feat(storage): add limit parameter to ClearRemovedEntriesContent
Without the limit, this query is going to hangs forever on large
databases with millions of entries.
2025-08-17 17:39:04 -07:00
gudvinr 905d652511 refactor(ui): use request builder in media proxy handler
builder is used seemingly everywhere but media proxy uses manual
transport construction
2025-08-17 13:09:32 -07:00
Frédéric Guillot a16ac51326 refactor(storage): split DeleteContentRemovedEntries into 2 functions and fix condition
- Add `content IS NOT NULL` to avoid clearing the same entries over and over
- Create `DeleteRemovedEntriesEnclosures` and `ClearRemovedEntriesContent` that report individual counts
2025-08-16 21:19:28 -07:00
jvoisin 5c26e06780 feat(entry): keep only metadata for removed entries
This should significantly shrink the space taken by miniflux' database:

```sql
miniflux=#
SELECT
  relname, pg_size_pretty(pg_total_relation_size(relname::regclass))
FROM
  pg_catalog.pg_statio_user_tables
ORDER BY
  pg_total_relation_size(relname::regclass)
DESC;

       relname        | pg_size_pretty
----------------------+----------------
 entries              | 158 MB
 icons                | 3312 kB
 enclosures           | 1568 kB
 sessions             | 1048 kB
 feeds                | 288 kB
 feed_icons           | 72 kB
 users                | 64 kB
 user_sessions        | 64 kB
 categories           | 48 kB
 integrations         | 32 kB
 api_keys             | 32 kB
 webauthn_credentials | 24 kB
 schema_version       | 16 kB
 acme_cache           | 16 kB
(14 rows)

miniflux=#
```

This should close #3524
2025-08-16 20:57:59 -07:00
Frédéric Guillot 9e722839b5 test: skip building a temporary binary for integration tests 2025-08-16 20:56:36 -07:00
Frédéric Guillot 9e4248c7c1 fix(templates): remove non-breaking space in about page 2025-08-16 16:51:39 -07:00
Frédéric Guillot a654a5f710 feat(template): show GitHub links in about page only when tag and commit are available 2025-08-16 13:08:38 -07:00
Frédéric Guillot 6bf3b3c464 fix(version): allow build info to be set with LDFLAGS and fallback to VCS metadata when available 2025-08-16 12:41:45 -07:00
Frédéric Guillot a010544200 fix(version): be explicit when VCS info is unavailable 2025-08-16 12:21:40 -07:00
Frédéric Guillot c1af510ead feat(version): use Golang's builtin vcs feature to get commit and build date 2025-08-16 12:05:53 -07:00
Frédéric Guillot 88d9682f5f ci: update Go version to 1.25 in workflows and Dockerfile 2025-08-16 12:05:07 -07:00
gudvinr ce6cadc176 refactor(mediaproxy): use *url.URL for MEDIA_PROXY_CUSTOM_URL
Same behaviour as for HTTP_CLIENT_PROXY.
2025-08-15 18:12:44 -07:00
Julien Voisin 40fa77851c refactor(locale): avoid code duplication in Printer.Printf() function 2025-08-15 18:02:25 -07:00
jvoisin dd8011a4aa refactor(template): remove some useless attributes
When `target="_blank"` is used, it has the same effect than rel="noopener",
so we can remove the latter. Moreover, since we're already setting `<meta
name="referrer" content="no-referrer" />` in the `<head>`, there is no need to
set it on every single link in the HTML, as we're rendering everything in the
same origin.

Note that we need to keep adding those in the sanitizer, the entry content HTML
can be consumed by third-party clients via the Miniflux/GoogleReader/Fever API.

See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/noopener
and https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#integration_with_html
2025-08-15 17:55:49 -07:00
Julien Voisin 3955df43f5 perf(js): don't load webauthn,js when disabled
This saves around 1kB of data, yay.
2025-08-15 17:11:12 -07:00
Julien Voisin 6f2ec8b3e9 refactor(ui): rename the small favicons to icons
There is no need to distinguish between "Android icons" and favicons.
2025-08-15 16:37:39 -07:00
jvoisin 2fa813fe01 refactor(fetcher): use errors.New when possible instead of fmt.Errorf 2025-08-15 16:25:58 -07:00
gudvinr 7c1602c2c0 refactor(internal): fix doc comments 2025-08-15 16:24:48 -07:00
gudvinr 5e3bba9ae1 refactor(request): fix typo in big corp name 2025-08-15 16:24:48 -07:00
gudvinr ab26a4e20f refactor(config): fix typos in test 2025-08-15 16:24:48 -07:00
Julien Voisin 2c4f4e2ae6 perf(templates): removes superfluous whitespaces in two templates 2025-08-14 19:40:30 -07:00
Julien Voisin 261b72f149 feat(icon): add resizing support for webp images 2025-08-14 19:36:11 -07:00
Julien Voisin 6c60d61f36 fix(icon): use rel=apple-touch-icon instead of rel=apple-touch-icon-precomposed.png
https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
2025-08-14 19:32:50 -07:00
Julien Voisin fa85a0eff4 feat(ui): disable OAuth routes if OAuth is disabled 2025-08-13 21:15:26 -07:00
jvoisin eb6f7f30bb feat(storage): make removed entries' status immutable
This is a first step towards "keeping only metadata for removed entries" #3524.
2025-08-12 20:17:34 -07:00
jvoisin 93fc206f42 refactor(opml): reduce indirections
Don't use a slice of pointers to opml items, when we can simply use a slice of
items instead. This should reduce the amount of memory allocations and the
number of indirections the GC has to process, speedup up the import process.

Note that this doesn't introduce any additional copies, as the only time a
slice of subscription is created, the items are created and inserted inline.
2025-08-12 19:47:47 -07:00
Julien Voisin 8bca777a6d refactor(model): remove some indirection
For small fixed-size structures, it's better to use a slice of values, instead
of a slice of pointers to values: they're stored contiguously and thus can be
iterated on quickly by the CPU, and it does remove an indirection per object
every time the GC kicks in.
2025-08-12 19:46:14 -07:00
dependabot[bot] 1e6d227e40 build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 16:49:06 -07:00
Frédéric Guillot 37d539eb42 fix(template): webauthn error alert was broken on the settings page 2025-08-11 20:01:53 -07:00
jvoisin f5188b1edc refactor(ui): don't expose webauthn routes if webauthn is disabled 2025-08-11 19:53:23 -07:00
Julien Voisin 5d9d0b2652 refactor(ui): standardize user variable naming and avoid a SQL query when only userID is used
- Use `user` everywhere, instead of sometimes `loggedUser`
- Delay the instantiation of some variables: no need to perform SQL queries for
  nothing.
- Remove a SQL query getting the whole user struct when only user.ID is used.
2025-08-11 19:48:36 -07:00
jvoisin 50c5996280 refactor: remove some fmt.Sprintf calls
fmt.Sprintf is slow, so let's get rid of it in trivial cases that are in (at
least) moderately hot paths.
2025-08-11 19:27:34 -07:00
jvoisin 884521a7dd refactor(template): use modern svg directive
https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/use
https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/xlink:href

> Warning: Since SVG 2, the xlink:href attribute is deprecated in favor of
href. See xlink:href page for more information.
2025-08-11 18:31:16 -07:00
jvoisin 3a01f8a691 perf(misc): use arrays instead of slices where possible
Arrays have a fixed size in go, while slices don't, making the former way
faster than the latter: https://go-benchmarks.com/array-vs-slice
2025-08-11 18:26:58 -07:00
jvoisin 68984da332 perf(static): minimize the SVG
Since tdewolff/minify supports SVG minimization, let's make use of it. As we
need to keep the license in the SVG because we're nice netizens, we can at
least use SPDX identifiers instead of using it verbatim.

This does save a couple of kB.
2025-08-09 15:38:43 -07:00
jvoisin 485baf9654 refactor(misc): fix a handful of TODO 2025-08-09 15:22:02 -07:00
Julien Voisin 06cbf1b3b3 fix(icon): update incorrect log messages 2025-08-09 15:20:33 -07:00
Frédéric Guillot b20a8c97b0 fix(ui): invert toast notification icons 2025-08-09 14:23:20 -07:00
Frédéric Guillot e0ca92fca4 fix(storage): revert DISTINCT in FetchJobs query 2025-08-08 16:53:52 -07:00
Frédéric Guillot 598d4d4f51 feat(storage): improve BatchBuilder logging 2025-08-08 16:34:36 -07:00
Frédéric Guillot a2229198ae feat(api): log request URI in authentication handlers 2025-08-08 14:27:23 -07:00
Frédéric Guillot 34499b887b feat: add POLLING_LIMIT_PER_HOST to limit concurrent requests per host
Each batch of feeds sent to the worker pool is now guaranteed to contain unique feed URLs.

When `POLLING_LIMIT_PER_HOST` is set, an additional limit is applied to the number of concurrent requests per hostname, helping to prevent overloading a single server.

Note: Additional requests may still be made during feed refresh. For example, to fetch feed icons or when the web scraper is enabled for a particular feed.
2025-08-08 12:33:46 -07:00
Tim Douglas a4f672b589 fix: URL detection incorrectly capturing newlines in media descriptions 2025-08-08 10:42:09 -07:00
Julien Voisin 98da7b3f22 feat(template): provide a link for the Apache 2.0 license in the about page
- Provide a link for the Apache 2.0 license
- Factorise the checks for IsAdmin
- Fix some indentation issues
2025-08-08 10:31:21 -07:00
jvoisin b4c82a4c53 perf(static): minimize images 2025-08-08 10:16:32 -07:00
Frédéric Guillot 4d7c601f6d feat(ui): add PWA app shortcuts
References:

- https://web.dev/articles/app-shortcuts
- https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts
2025-08-07 20:47:08 -07:00
Frédéric Guillot f7e672452b feat(js): force page reload to prevent stale data from bfcache
The unread page may show outdated entries when navigating back from an article, due to Chrome's back/forward cache (bfcache) restoring the page from memory.

Reference: https://web.dev/articles/bfcache
2025-08-07 19:35:16 -07:00
Frédéric Guillot 6532435db9 fix(css): align icon labels vertically in entry actions 2025-08-07 17:50:17 -07:00
jvoisin 14cd5e9c0d refactor(template): reduce the amount of code in common templates
There is no need to have templates used only used in a single file be part of
every single other ones. This should reduce a bit the resident memory
consumption of miniflux.
2025-08-07 17:36:41 -07:00
Julien Voisin 566670cc06 refactor: unexport symbols 2025-08-07 17:27:04 -07:00
dependabot[bot] a4d51b5586 build(deps): bump golang.org/x/net from 0.42.0 to 0.43.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/net/compare/v0.42.0...v0.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:25:08 -07:00
dependabot[bot] e6d01896e0 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.10 to 2.23.11
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.10 to 2.23.11.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.10...v2.23.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:15:46 -07:00
dependabot[bot] b4a814a5b2 build(deps): bump golang.org/x/term from 0.33.0 to 0.34.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/term/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:14:36 -07:00
dependabot[bot] 940d94e793 build(deps): bump golang.org/x/image from 0.29.0 to 0.30.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/image/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:04:43 -07:00
Frédéric Guillot 80f48c88c7 refactor: replace interface{} with any 2025-08-05 20:26:07 -07:00
Frédéric Guillot 045f2f1747 chore(view): remove unused webauthn_js_checksum key 2025-08-05 20:20:33 -07:00
Julien Voisin 2565ff930c refactor(template): make use of template.ParseFS 2025-08-05 20:07:49 -07:00
Julien Voisin b3fce752d2 refactor: replace Sprintf with string concatenation
There is no need to have go parse the whole format string then introspect the
arguments when we an simply use string concatenation.
2025-08-05 19:47:26 -07:00
Julien Voisin 798bc4cd2d refactor(static): use a simple struct instead of two slices to store assets data and checksums
- Use a simple struct instead of two slices to store the data and the checksums
  of resources
- Remove a superfluous call to Sprintf
- Factorise presence check and data retrieval in some maps
- Size the maps when possible
2025-08-05 19:35:27 -07:00
Julien Voisin b1cbaae71c refactor(storage): simplify feed.go by using min(), inline errors, and use idiomatic conditions
- Use `min` instead of doing the comparison by hand.
- Inline error handling where it makes sense
- Invert a condition to make it more idiomatic
2025-08-05 19:25:50 -07:00
Julien Voisin 924293ee5c perf(storage): avoid heavy-weight SQL when marking feed as read
There is no need to perform a heavy-weight SQL query gathering all the
information available on a feed when we're only interested in its last check
timestamp.
2025-08-05 18:01:58 -07:00
Julien Voisin 826977bc8c perf(api): speed up markFeedAsRead by avoiding a JOIN operation 2025-08-05 17:55:15 -07:00
Julien Voisin ed0e4a667d refactor(template): reindent and merge tags in feed_list.html
- Fix some indentation
- Factorize/merge some <span>
- Remove superfluous spaces
- Reindent some nested tags
2025-08-05 17:50:09 -07:00
jvoisin 5affd78f4f refactor(reader): move the fetcher outside of a loop
There is no need to rebuilt the fetcher for every item, creating it once is
enough.
2025-08-05 17:39:23 -07:00
Frédéric Guillot d3ad460c9d Revert "feat(cookie): use SameSiteStrictMode when not using OAuth2/OIDC"
This reverts commit 135ce1d546.

People using Miniflux as PWA on Android are constantly being logged out.
2025-08-05 17:32:20 -07:00
Frédéric Guillot 0f3c04a98a test(rewrite): fix flaky test case by sorting query string keys 2025-08-05 17:31:43 -07:00
gudvinr e8e3a600b4 refactor(googlereader): remove intermediate variable 2025-08-03 13:21:40 -07:00
Julien Voisin a6ce5c92dc perf(storage): minor optimization for FetchJobs
- Replace a call to fmt.Sprintf with a concatenation
- Explicit declaration of return values in FetchJobs
- Initialize the size of FetchJobs return value to b.limit: when b.limit is
  used, which is most of the time, this avoid resizing the slice, and when it
  isn't, the size of the map is set to 0, which is equivalent to the previous
  situation anyway.
- Move a call to `request.UserID(r)` to a lower scope.
2025-08-03 13:19:14 -07:00
gudvinr 76ef8f3579 refactor(googlereader): replace Sprintf("%d") with FormatInt
see https://medium.com/swlh/bad-go-frivolous-sprintf-2ad28fedf1a0
2025-08-03 13:15:49 -07:00
Julien Voisin a43d150a27 refactor(parser): centralize seek logic and provide a hint for the compiler to eliminate a useless bound check
- Move the seeking inside of DetectFeedFormat instead of having it everywhere
  in ParseFeed
- Provide a hint for the compiler to eliminate a useless bound check in
  DetectJSONFormat, otherwise it'll check that buffer[i] is valid on every
  iteration of the loop. This shouldn't make a big difference, but oh well.
2025-08-03 12:53:10 -07:00
Frédéric Guillot 3bb965913d refactor(js): create utility functions to manage buttons state 2025-08-02 21:37:16 -07:00
Frédéric Guillot b505a63f3b refactor(js): rewrite toast notification implementation 2025-08-02 21:37:16 -07:00
Frédéric Guillot e9d9256ae2 refactor(js): rename functions to include action suffix 2025-08-02 18:44:12 -07:00
Frédéric Guillot 391792a424 refactor(js): combine handleBookmark and toggleBookmark functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot d2cfca589b refactor(js): combine handleSaveEntry() and saveEntry() functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot f2e34cf07f refactor(js): split openOriginalLink() into smaller functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot 5c3be3e74f refactor(js): combine handleShare() and triggerWebShare() functions 2025-08-02 18:44:12 -07:00
jvoisin 546fbcff8f perf(storage): pair all SELECT true with LIMIT 1
Apparently, postgresql isn't smart enough to realize that once a true value
value is found as part of a `SELECT true`, there is no need to scan the rest of
the table, so we have to make this explicit. We could also have used the
`SELECT EXISTS(…)` construct, but it's more verbose and I don't like it.
2025-08-02 16:13:55 -07:00
Frédéric Guillot 2e28bf78bd refactor(js): improve item navigation logic in goToListItem function 2025-08-02 16:12:53 -07:00
Frédéric Guillot 52c1386450 refactor(js): enable touch handlers only on touch devices and fix various issues in WebAuthnHandler 2025-08-02 15:39:01 -07:00
Frédéric Guillot 4910f1f0f4 refactor(js): remove RequestBuilder 2025-08-02 15:14:35 -07:00
Frédéric Guillot bbe3c2ea71 refactor(js): simplify some functions using modern JavaScript 2025-08-02 14:06:18 -07:00
Frédéric Guillot b116da85a9 refactor(js): remove bootstrap.js 2025-08-02 13:41:40 -07:00
Frédéric Guillot 07246e2b59 refactor(js): improve menu handlers 2025-08-02 13:09:57 -07:00
Frédéric Guillot 62410659d5 refactor(js): code cleanup and add jshint comments 2025-08-02 12:38:29 -07:00
Frédéric Guillot 3e1a7e411c feat(js): register the service worker as JavaScript module 2025-08-02 11:26:43 -07:00
Frédéric Guillot bfbc1c88c3 feat(js): load app.js using JavaScript module
- The JS bundle has its own isolated scope
- There is no need to use IIFEs anymore (Immediately Invoked Function Expressions)
- Modules are executed after the HTML document is fully parsed, similar to `defer` attribute
- There is no need to use `DOMContentLoaded` anymore
- Module scripts inherently run in strict mode (no need to define `use strict` anymore)
2025-08-02 11:07:27 -07:00
Frédéric Guillot 50197c2be3 refactor(js): reorder functions and add comments 2025-08-01 21:56:25 -07:00
Frédéric Guillot 7a25cf5037 fix(js): handle multiple buttons in a single form when showing loading state 2025-08-01 20:53:59 -07:00
Frédéric Guillot 1ec90e34f5 refactor(js): simplify CSRF token retrieval from the document 2025-08-01 20:44:40 -07:00
Frédéric Guillot 5e07278e87 feat(ui): refresh the page when marking as read the last visible entry 2025-08-01 20:16:45 -07:00
Julien Voisin cce0e7bd29 refactor(rewrite): replaced regex-based YouTube and Invidious video ID extraction with URL parsing 2025-08-01 17:44:12 -07:00
Frédéric Guillot 1f7843e313 feat(integration): prioritize feed-level webhook URL when available when saving entries 2025-07-31 19:56:44 -07:00
Julien Voisin 181e1341e1 refactor(locale): introspect the translation files at load time
Since Go doesn't support unions, and because the translation format is a bit
wacky with the same field having multiple types, we must resort to
introspection to switch between single-item translation (for singular), and
multi-items ones (for plurals).

Previously, introspection was done at runtime, which is not only slow, but will
also only catch typing errors while trying to use the translations. The current
approach is to use a struct with a different field per possible type, and
implement a custom unmarshaller to dispatch the translations to the right one.
This should marginally reduce the memory consumption since interface-boxing
doesn't happen anymore, speed up the translations matching, and enforce proper
typing earlier. This also allows us to remove a bunch of now-useless tests.
2025-07-31 19:10:14 -07:00
jvoisin f3052eb8ed refactor(misc): make use of type constraints where possible 2025-07-31 18:59:55 -07:00
Julien Voisin 078eb39db9 refactor(config): don't check random.Read's return value
As stated in the documentation:

> Read calls io.ReadFull on Reader and crashes the program irrecoverably if an
error is returned. The default Reader uses operating system APIs that are
documented to never return an error on all but legacy Linux systems.
2025-07-31 18:03:53 -07:00
dependabot[bot] 6aeb565edc build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.23.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.22.0...v1.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-31 16:51:21 -07:00
Frédéric Guillot 0c3e251884 refactor(filter): parse and merge filters only once per refresh 2025-07-30 21:34:03 -07:00
Frédéric Guillot bfd8cb3d22 feat(ui): add icons to main menu 2025-07-30 19:12:50 -07:00
jvoisin 9eea9873b5 feat(rewrite): add a rule to remove useless heading images on phoronix 2025-07-30 18:53:04 -07:00
dependabot[bot] 240ec8bd0a build(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.15.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.15.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 18:05:45 -07:00
Frédéric Guillot f3ac4dacf6 test(rewrite): add unit tests for addYoutubeVideoFromId and addInvidiousVideo functions 2025-07-29 21:51:15 -07:00
Frédéric Guillot 13986e9cc0 docs: remove ChangeLog file
Maintaining a separate ChangeLog file is redundant and error-prone,
as it largely duplicated the Git commit history without adding meaningful context.

Release notes are still available on GitHub Releases and the Miniflux website.
2025-07-26 15:24:00 -07:00
Frédéric Guillot 4d7d554df8 docs: update release notes for version 2.2.11 2025-07-26 13:13:53 -07:00
Frédéric Guillot 66b269e6cd feat(readability): avoid removing elements with content class 2025-07-25 19:59:47 -07:00
Frédéric Guillot 54abd0a736 fix(parser): handle feeds with leading whitespace that exceeds buffer size 2025-07-23 21:06:15 -07:00
Frédéric Guillot 5eab4753e8 refactor(googlereader): rename/unexport response types and functions 2025-07-23 20:36:04 -07:00
Frédéric Guillot bf466425db ci: update commit linter to allow default Git revert message 2025-07-23 20:03:04 -07:00
jvoisin a62b97bddd refactor(readability): get rid of getClassWeight
Its naming was confusing, and its code simple enough that it could be inlined.
2025-07-23 19:55:47 -07:00
jvoisin 1de9cf4241 perf(readability): simplify removeUnlikelyCandidates
- Use an iterator instead of generating a whole slice when iterating on the selection.
- Using an iterator allows to use a for-loop construct, instead of a lambda,
  which is a bit clearer
- Do the filtering Find()'s selector, instead of in the loop, which doesn't
  matter much now that we're using an iterator, but it makes the code a bit
  more obvious/simpler, and likely reduces a bit the number of iterations.
2025-07-23 19:55:47 -07:00
jvoisin 7912b9b8fb perf(readability): avoid materializing text to count commas
There is no need to materialize the whole text content of the selection only to
count its number of commas. As we already have a getLengthOfTextContent
function that is pretty similar, this commit refactors it to make it more
generic, in the form of a map/fold(+).
2025-07-23 19:55:47 -07:00
jvoisin 2d24f5d04e refactor(readability): minor code folding 2025-07-23 19:55:47 -07:00
Frédéric Guillot 20825a92c5 Revert "perf(template): use ParseFS to directly parse the embedded template data"
This reverts commit 4336a0bd85.
2025-07-22 21:31:34 -07:00
Frédéric Guillot 1d1162327e feat(integration): use Bearer token authorization instead of cookie for Linkwarden client 2025-07-22 21:13:48 -07:00
Frédéric Guillot 202de7c787 fix(integration): rename Linkwarden endpoint label to base URL (#3568) 2025-07-22 20:49:54 -07:00
Frédéric Guillot b470b186b3 feat(makefile)!: remove unsupported platforms and stop distributing Windows binary
There is no installation wizard for Windows and running the
command line binary as-is could lead to confusion for some users.

Unit tests still run on Windows, and people can still compile from
source if interested.
2025-07-22 20:24:39 -07:00
dependabot[bot] 5877cf340a build(deps): bump github.com/go-webauthn/webauthn from 0.13.3 to 0.13.4
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.3 to 0.13.4.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.3...v0.13.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 17:31:06 -07:00
dependabot[bot] d417b0ff12 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.9 to 2.23.10
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.9 to 2.23.10.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.9...v2.23.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 17:27:56 -07:00
Frédéric Guillot 703f113fbd fix(storage): ensure strings are truncated to fit tsvector size limit and remain valid UTF-8 2025-07-20 13:42:58 -07:00
Frédéric Guillot f02213a168 refactor(readability): use String explicitly in debug log instead of Any 2025-07-19 10:58:49 -07:00
Frédéric Guillot 410b43a787 chore(contrib): update PostgreSQL image from 15 to latest in docker-compose examples 2025-07-19 10:51:00 -07:00
Frédéric Guillot d9de9d1852 feat(rss): fallback to enclosure URL when entry URL is missing 2025-07-19 10:46:43 -07:00
dependabot[bot] 33d55cc4e9 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.8 to 2.23.9
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.8 to 2.23.9.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.8...v2.23.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-18 17:35:03 -07:00
Julien Voisin 1825320369 refactor(template): remove unused functions and reduce the complexity of truncate function
- Remove unused functions like hasKey, domain, hasPrefix and contains.
- Lower the complexity of truncate from O(n) to O(1).
2025-07-17 20:53:41 -07:00
jvoisin d80fb242db refactor(template): reduce translation-related introspection
Keys in translation maps are always strings, never anything else, so there is
no need to introspect them.
2025-07-17 20:48:36 -07:00
Julien Voisin 4336a0bd85 perf(template): use ParseFS to directly parse the embedded template data
Use ParseFS to directly parse the embedded template data, instead of manually
reading it and then using Parse.
2025-07-17 20:46:33 -07:00
Frédéric Guillot dc81725788 fix(filter): remove \r\n in rule parsing 2025-07-16 21:03:53 -07:00
Julien Voisin 86e2ce6d0b perf(readability): move transformMisusedDivsIntoParagraphs call after removeUnlikelyCandidates 2025-07-13 14:34:14 -07:00
Frédéric Guillot 4679691c94 refactor(googlereader): rename stream suffix constants for clarity 2025-07-13 14:28:25 -07:00
Julien Voisin 0d5f4a710f refactor(googlereader): unexport a lot of symbols 2025-07-13 14:16:08 -07:00
dependabot[bot] 92d2ac4f58 build(deps): bump github.com/go-webauthn/webauthn from 0.13.1 to 0.13.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.1 to 0.13.3.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.1...v0.13.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-11 17:09:37 -07:00
dependabot[bot] 1cfee27a50 build(deps): bump golang.org/x/image from 0.28.0 to 0.29.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/image/compare/v0.28.0...v0.29.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 19:32:49 -07:00
jvoisin 0e9da3a090 refactor(icon): simplify findIconURLsFromHTMLDocument
- Don't define the queries before possible early returns
- Check for the presence of the href attribute in the queries, instead of later
  on iterating on the selection
- Add two edge-cases to the tests
- Use EachIter instead of Each, if only to avoid the lambda
2025-07-10 19:32:29 -07:00
jvoisin 57bd384951 refactor(icon): unexport a bunch of symbols 2025-07-10 19:32:29 -07:00
dependabot[bot] fdbd5b08a1 build(deps): bump golang.org/x/net from 0.41.0 to 0.42.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.41.0 to 0.42.0.
- [Commits](https://github.com/golang/net/compare/v0.41.0...v0.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 19:24:08 -07:00
jvoisin f455c18c66 perf(rewrite): anchor the rewrite regex
There is no need to try to match the regexp over the whole input, having it
anchored is enough. If we feel extra-lenient, we might strip spaces in
front/tail, but I don't think it's necessary.

This commit also invert a condition to reduce the level of nested indentation,
and make a condition stricter.
2025-07-10 19:23:54 -07:00
dependabot[bot] 9dea26c923 build(deps): bump golang.org/x/crypto from 0.39.0 to 0.40.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 17:33:04 -07:00
jvoisin 46adb0ffad refactor(fetcher): simplification of ExecuteRequest
Instead of doing some ciphers manipulation before instantiating the http.Transport
and then assigning them, instantiate http.Transport, and then in an if do the
manipulation. This makes the code a bit clearer, which is always nice when it
comes to cryptographic shenanigans.
2025-07-09 19:36:36 -07:00
jvoisin 61583d53d5 refactor(config): simplify SortedOptions
Make use of the slices and maps packages instead of doing things by hand,
and pre-allocated sortedOptions.
2025-07-09 19:29:53 -07:00
jvoisin 7c42e777ec refactor(config): minor improvements of the config parser
- Surface the faulty line number when trying to parse it
- Use strings.Cut instead of strings.SplitN
- Use strings.TrimSuffix instead of an if
- Simplify parseStringList and make its code more compact
2025-07-09 19:28:02 -07:00
dependabot[bot] 335dffbb75 build(deps): bump golang.org/x/term from 0.32.0 to 0.33.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/term/compare/v0.32.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-09 19:18:08 -07:00
Qeynos f0cdfb33dd feat(locale): update Chinese translations 2025-07-09 19:14:40 -07:00
jvoisin 13ef89f785 fix(storage): don't truncate title/content fields
As found out by @fguillot in https://github.com/miniflux/v2/pull/3461#issuecomment-3047204354
2025-07-08 19:36:18 -07:00
jvoisin 32fbb4e882 fix(ui): improve margins consistency wrt. header/footer 2025-07-08 19:30:13 -07:00
Julien Voisin 135ce1d546 feat(cookie): use SameSiteStrictMode when not using OAuth2/OIDC 2025-07-08 19:20:24 -07:00
Cthulhux abed7b11ce feat(locale): update German translation
Missed two weird translations and one typo
2025-07-08 19:17:28 -07:00
Frédéric Guillot 2e26f5ca75 test(reader): ensure consistent tags parsing across feed formats 2025-07-07 20:07:35 -07:00
jvoisin d6d18a2d61 perf(reader): shrink the json detection buffer
There is no need to allocate half a kilobyte of memory only check that a buffer
starts with a bunch of spaces and a `{`, 32b should be more than enough. Also,
no need to allocate it on the heap, having it on the stack works perfectly.
2025-07-07 19:21:59 -07:00
Frédéric Guillot 63891501e5 refactor(model): add test coverage and simplify ProxifyEnclosureURL 2025-07-07 18:41:44 -07:00
dependabot[bot] 7107ff985f build(deps): bump github.com/go-webauthn/webauthn from 0.13.0 to 0.13.1
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.0 to 0.13.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.0...v0.13.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 18:39:51 -07:00
jvoisin 50d5cb96c8 refactor(enclosure): simplify model/enclosure.go
- Reuse isAudio and isVideo instead of re-implementing them
- In IsImage, return early is the mimetype is enough, instead of systematically
  lowercasing the url as well.
- Extract the common parts of the two ProxifyAbsoluteURL implementations into a
  private function, make the code smaller and clearer.
- Fix a logic error where `A && B || C` was used instead of `A && (B || C)
2025-07-07 17:42:44 -07:00
Frédéric Guillot f860daef7f test(locale): increase test coverage to 100% 2025-07-07 17:39:56 -07:00
Frédéric Guillot e7b98afdbe refactor(subscription): avoid using Sprintf to construct Youtube playlist feed URL 2025-07-07 17:08:47 -07:00
Frédéric Guillot 2cfeefc8d2 test(processor): increase test coverage for parseISO8601Duration 2025-07-07 17:01:10 -07:00
jvoisin b48e6472f5 refactor(processor): parse ~ISO8601 in a proper way
Instead of using an ugly (and incomplete) regex, let's use a simple for-loop to
parse ISO8601 dates, and make it explicit that we're only supporting a subset
of the spec, as we only care about youtube video durations.
2025-07-07 16:28:58 -07:00
jvoisin 24043ece07 refactor(config): unexport some symbols 2025-07-07 16:01:21 -07:00
jvoisin a09129d220 refactor(proxyrotator): simplify mutex handling
There is no need to use a mutex to check the length of the proxies list,
as it's read-only during the whole lifetime of a ProxyRotator structure.
Moreover, it's a bit clearer to explicitly wrap the 2 lines of mutex-needing
operations between a Lock/Unlock instead of using a defer.
2025-07-07 15:52:16 -07:00
jvoisin f864a2ed70 refactor(mediaproxy): simplify shouldProxy
The original function was non-trivial to understand, as `!A && (B || !C)` isn't
easily grokable by humans.
2025-07-07 15:50:13 -07:00
jvoisin 052e8dd0aa refactor(config): remove two unused constants 2025-07-07 15:45:20 -07:00
jvoisin 7a394b0bf8 refactor(subscription): replace a regex with strings.CutPrefix 2025-07-07 15:44:45 -07:00
jvoisin dcfe0a7d94 refactor(locale): simplify pluralForm
Instead of having a switch-case returning a function to be executed, it's
simpler/faster to have a single function containing a switch-case. It also
allows to group languages with identical plural form in a single
implementation, and remove the "default" guard value, as switch-case already
have a `default:` case.
2025-07-07 15:30:41 -07:00
jvoisin 33c648825f refactor(locale): make Printf's code structure similar to Print's
And also change the order of the cases in the Plural function, to make it
explicit that []string shouldn't match []any.
2025-07-07 15:30:41 -07:00
jvoisin 78c7f66df7 refactor(locale): remove a call to fmt.Sprintf 2025-07-07 15:30:41 -07:00
jvoisin f2b805850c refactor(locale): remove an unused function 2025-07-07 15:30:41 -07:00
jvoisin 915b7b3cf7 refactor(locale): unexport a symbol 2025-07-07 15:30:41 -07:00
jvoisin 8e86004936 refactor(locale): use any instead of interface{} 2025-07-07 15:30:41 -07:00
Julien Voisin a8b4e88742 perf(sanitizer): improve the performances of the sanitizer (#3497)
- Grow the underlying buffer of SanitizeHTML's strings.Builder to 3/4 of the
  raw HTML from the start, to reduce the amount of iterative allocations. This
  number is a complete guesstimation, but it sounds reasonable to me.
- Add a `absoluteURLParsedBase` function to avoid parsing baseURL over and over.
2025-07-07 15:21:13 -07:00
jvoisin 15e4c3a374 refactor(database): get rid of the sqlite tentative 2025-07-02 17:18:03 -07:00
jvoisin 69a74c4abf refactor(readability): minor clean up
Remove a now-useless regex and its associated test.
2025-07-02 16:50:49 -07:00
jvoisin 766d4ab834 refactor(readability): make use of getSelectionLength 2025-07-02 16:47:27 -07:00
Frédéric Guillot cb617ff6e0 test(sanitizer): enhance tests for image width and height attributes 2025-07-01 20:52:45 -07:00
Frédéric Guillot 8c3f280f32 test(readability): add test case for ExtractContent with broken reader 2025-07-01 20:14:52 -07:00
jvoisin 8a98926674 refactor(readability): add a getSelectionLength function
When we're only interested in the length of contained Text, there is no need to
materialize it fully to then call len() on the result: we can simply iterate
over the text element and sum their length instead.
2025-07-01 19:52:53 -07:00
jvoisin 435a950d64 refactor(sanitizer): minor refactorization
Use a proper switch-case instead of a bunch of if.
2025-07-01 19:48:55 -07:00
jvoisin 89c32d518d perf(readability): significantly improve transformMisusedDivsIntoParagraphs 2025-07-01 19:44:58 -07:00
jvoisin 2f7b2e7375 perf(readability): improve getLinkDensity
- There is no need to materialize all the content of a given Node when we can
  simply compute its length directly, saving a lot of memory, on the order of
  several megabytes on my instance, with peaks at a couple of dozen.
- One might object to the usage of a recursive construct, but this is a direct
  port of goquery's Text method, so this change doesn't make anything worse.
- The computation of linkLength can be similarly computed, but this can go in
  another commit, as it's a bit trickier, since we need to get the length of
  every Node that has a `a` Node as parent, without iterating on the whole
  parent chain every time.
2025-07-01 19:40:47 -07:00
Frédéric Guillot 6eeccae7cd test(readability): increase test coverage 2025-06-30 21:29:07 -07:00
jvoisin 99c5bcdb01 perf(storage): truncate strings on go's side instead of pgsql's
There is no need to send the whole title and content to have them truncated on
postgresql's side when we can do this client-side. This should save some
memory on the database's side, as well as some bandwidth
if it's located on another server. And it makes the SQL queries a tad more
readable as well.
2025-06-30 19:45:45 -07:00
jvoisin aed99e65c1 perf(readability): improve getClassWeight speed
Before

```console
$ go test -bench=.
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/readability
BenchmarkExtractContent-8   	     34	 86102474 ns/op
BenchmarkGetWeight-8        	  10573	    103045 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/readability	5.409s
```

After

```console
$ go test -bench=.
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/readability
BenchmarkExtractContent-8   	     56	 83130924 ns/op
BenchmarkGetWeight-8        	 246541	     5241 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/readability	6.026s
```

This should make ProcessFeedEntries marginally faster, while saving
some memory.
2025-06-30 19:28:20 -07:00
jvoisin d1a3f98df9 perf(fetcher): save 8 bytes in the RequestBuilder struct
before:

```
  // request_builder.go:25 | Size: 64 (Optimal: 56)
  type RequestBuilder struct {
    headers          http.Header                 ■ ■ ■ ■ ■ ■ ■ ■
    clientProxyURL   *url.URL                    ■ ■ ■ ■ ■ ■ ■ ■
    useClientProxy   bool                        ■ □ □ □ □ □ □ □
    clientTimeout    int                         ■ ■ ■ ■ ■ ■ ■ ■
    withoutRedirects bool                        ■
    ignoreTLSErrors  bool                          ■
    disableHTTP2     bool                            ■ □ □ □ □ □
    proxyRotator     *proxyrotator.ProxyRotator  ■ ■ ■ ■ ■ ■ ■ ■
    feedProxyURL     string                      ■ ■ ■ ■ ■ ■ ■ ■
                                                 ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
  // request_builder.go:25 | Size: 56
  type RequestBuilder struct {
    headers          http.Header                 ■ ■ ■ ■ ■ ■ ■ ■
    clientProxyURL   *url.URL                    ■ ■ ■ ■ ■ ■ ■ ■
    clientTimeout    int                         ■ ■ ■ ■ ■ ■ ■ ■
    useClientProxy   bool                        ■
    withoutRedirects bool                          ■
    ignoreTLSErrors  bool                            ■
    disableHTTP2     bool                              ■ □ □ □ □
    proxyRotator     *proxyrotator.ProxyRotator  ■ ■ ■ ■ ■ ■ ■ ■
    feedProxyURL     string                      ■ ■ ■ ■ ■ ■ ■ ■
                                                 ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 112494bb66 perf(feed): save 16 bytes in the Feed struct
before:

```
  // feed.go:25 | Size: 560 (Optimal: 544)
  type Feed struct {
    ID                          int64      ■ ■ ■ ■ ■ ■ ■ ■
    UserID                      int64      ■ ■ ■ ■ ■ ■ ■ ■
    FeedURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    SiteURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Title                       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Description                 string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    CheckedAt                   time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NextCheckAt                 time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    EtagHeader                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    LastModifiedHeader          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorMsg             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorCount           int        ■ ■ ■ ■ ■ ■ ■ ■
    ScraperRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool       ■ □ □ □ □ □ □ □
    BlocklistRules              string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Disabled                    bool       ■
    NoMediaPlayer               bool         ■
    IgnoreHTTPCache             bool           ■
    AllowSelfSignedCertificates bool             ■
    FetchViaProxy               bool               ■
    HideGlobally                bool                 ■
    DisableHTTP2                bool                   ■ □
    AppriseServiceURLs          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    WebhookURL                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NtfyEnabled                 bool       ■ □ □ □ □ □ □ □
    NtfyPriority                int        ■ ■ ■ ■ ■ ■ ■ ■
    NtfyTopic                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    PushoverEnabled             bool       ■ □ □ □ □ □ □ □
    PushoverPriority            int        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Category                    *Category  ■ ■ ■ ■ ■ ■ ■ ■
    Icon                        *FeedIcon  ■ ■ ■ ■ ■ ■ ■ ■
    Entries                     Entries    ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    TTL                         int        ■ ■ ■ ■ ■ ■ ■ ■
    IconURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UnreadCount                 int        ■ ■ ■ ■ ■ ■ ■ ■
    ReadCount                   int        ■ ■ ■ ■ ■ ■ ■ ■
    NumberOfVisibleEntries      int        ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
// feed.go:25 | Size: 544
  type Feed struct {
    ID                          int64      ■ ■ ■ ■ ■ ■ ■ ■
    UserID                      int64      ■ ■ ■ ■ ■ ■ ■ ■
    FeedURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    SiteURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Title                       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Description                 string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    CheckedAt                   time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NextCheckAt                 time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    EtagHeader                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    LastModifiedHeader          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorMsg             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorCount           int        ■ ■ ■ ■ ■ ■ ■ ■
    ScraperRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Disabled                    bool       ■
    NoMediaPlayer               bool         ■
    IgnoreHTTPCache             bool           ■
    AllowSelfSignedCertificates bool             ■
    FetchViaProxy               bool               ■
    HideGlobally                bool                 ■
    DisableHTTP2                bool                   ■
    PushoverEnabled             bool                     ■
    NtfyEnabled                 bool       ■
    Crawler                     bool         ■ □ □ □ □ □ □
    AppriseServiceURLs          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    WebhookURL                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NtfyPriority                int        ■ ■ ■ ■ ■ ■ ■ ■
    NtfyTopic                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    PushoverPriority            int        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Category                    *Category  ■ ■ ■ ■ ■ ■ ■ ■
    Icon                        *FeedIcon  ■ ■ ■ ■ ■ ■ ■ ■
    Entries                     Entries    ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    TTL                         int        ■ ■ ■ ■ ■ ■ ■ ■
    IconURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UnreadCount                 int        ■ ■ ■ ■ ■ ■ ■ ■
    ReadCount                   int        ■ ■ ■ ■ ■ ■ ■ ■
    NumberOfVisibleEntries      int        ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 9f7ecdb75a perf(model): save 16 bytes in the FeedCreationRequest struct
before:

```
  // feed.go:154 | Size: 240 (Optimal: 224)
  type FeedCreationRequest struct {
    FeedURL                     string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    CategoryID                  int64   ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool    ■
    Disabled                    bool      ■
    NoMediaPlayer               bool        ■
    IgnoreHTTPCache             bool          ■
    AllowSelfSignedCertificates bool            ■
    FetchViaProxy               bool              ■ □ □
    UrlRewriteRules             string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    DisableHTTP2                bool    ■ □ □ □ □ □ □ □
    ScraperRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    HideGlobally                bool    ■ □ □ □ □ □ □ □
    ProxyURL                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
  // feed.go:154 | Size: 224
  type FeedCreationRequest struct {
    FeedURL                     string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    CategoryID                  int64   ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool    ■
    Disabled                    bool      ■
    NoMediaPlayer               bool        ■
    IgnoreHTTPCache             bool          ■
    AllowSelfSignedCertificates bool            ■
    FetchViaProxy               bool              ■
    HideGlobally                bool                ■
    DisableHTTP2                bool                  ■
    ScraperRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 4e1f836266 refactor(readability): simplify a bit getArticle
- Use a proper division instead of multiplying by a float.
- Extract a condition in the parent scope
- Use an else-if construct instead of a simple if
2025-06-29 16:06:34 -07:00
Frédéric Guillot a68de4ee6a test(readability): add tests for getArticle function 2025-06-29 16:03:17 -07:00
jvoisin c064891314 perf(readability): Simplify removeUnlikelyCandidates
- Use an array of strings instead of a regex, like done in ef13756b1a7a7ba30fd34174a5367381fd8b4849
- Extract the `shouldRemove` function from `removeUnlikelyCandidates`, as there
  is no reason to have it there instead of being a proper standalone function.
- Improve a condition, where the goquery selection would have its `id`
  attribute left unchecked if a `class` one was present, regardless of if
  `class` was a candidate to removal or not.
- Add some comments
2025-06-29 15:31:01 -07:00
Frédéric Guillot 5129f53d58 test(readability): add tests for removeUnlikelyCandidates function 2025-06-29 15:23:56 -07:00
Frédéric Guillot e60f0fd142 test(readability): add tests for getClassWeight function 2025-06-29 13:24:06 -07:00
Julien Voisin 2b26a345cd perf(processor): minify content even further
There is no need to keep comments (conditionals or not, as IE isn't a thing
anymore), nor default attribute values.
2025-06-29 12:55:34 -07:00
Frédéric Guillot 3de31a1a4d test(processor): add more unit tests for minifyContent function 2025-06-29 12:53:23 -07:00
jvoisin 560be66147 refactor(misc): Use proper slog.XXX instead of slog.Any
This has close to no impact for now, as our slog.Debug/Info/... are leaking
their parameters to the heap, but using proper typing instead of Any allows
to skip some reflection-based computation, making things marginally faster,
and removing the corresponding heap leak.
2025-06-29 12:30:17 -07:00
Ingmar Stein fcf86e33b9 feat: TLS support for Unix socket listeners
This change enables Miniflux to serve TLS over Unix domain sockets.

If `CERT_FILE` and `KEY_FILE` are configured, Unix socket listeners
specified via `LISTEN_ADDR` will now automatically start with TLS enabled,
using the provided certificates. This uses the existing `http.Server.ServeTLS`
method.

If no certificates are provided, Unix socket listeners will continue to
operate as plain, non-TLS sockets.
2025-06-24 21:25:55 -07:00
dependabot[bot] 113f6b8982 build(deps): bump github.com/andybalholm/brotli from 1.1.1 to 1.2.0
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.1.1 to 1.2.0.
- [Commits](https://github.com/andybalholm/brotli/compare/v1.1.1...v1.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-24 19:52:21 -07:00
jvoisin cbdcf1a56c Revert "perf(storage): take advantage of entries_feed_id_hash_key in updateEntry"
This reverts commit 6527c04307.
2025-06-24 19:51:21 -07:00
Frédéric Guillot 95eb6c1230 chore(docker): update golang base image to alpine 3.22 2025-06-23 19:11:03 -07:00
jvoisin 643b89ec89 perf(storage): take advantage of entries_feed_id_hash_key in updateEntry 2025-06-23 17:59:17 -07:00
Frédéric Guillot 84ebf1a033 docs(manpage): update LISTEN_ADDR description 2025-06-23 17:51:02 -07:00
Frédéric Guillot 875618d786 docs(changelog): update release notes for version 2.2.10 2025-06-23 16:57:29 -07:00
Frédéric Guillot 1503a5c946 docs: add CONTRIBUTING.md file 2025-06-22 12:44:02 -07:00
jvoisin 8641f5f2a3 refactor(database): drop 3 columns in a single transaction 2025-06-20 16:23:20 -07:00
jvoisin 93b17af78b refactor(appjs): no need to check if always present elements are always present 2025-06-20 13:16:57 -07:00
Frédéric Guillot 92876a0c61 refactor(http): rename package from httpd to server for consistency 2025-06-20 13:15:13 -07:00
Frédéric Guillot d62df4e02a refactor(server): avoid double call to Sprintf 2025-06-20 13:05:21 -07:00
Ingmar Stein 8fa5041c37 feat: Allow multiple listen addresses
This change implements the ability to specify multiple listen addresses.
This allows the application to listen on different interfaces or ports simultaneously,
or a combination of IP addresses and Unix sockets.

Closes #3343
2025-06-20 12:46:34 -07:00
Julien Voisin dc05965895 chore(template): remove X-UA-Compatible meta tag specific to Internet Explorer 2025-06-20 11:59:08 -07:00
jvoisin 109e668ac7 perf(storage): pre-allocate a slice in RefreshFeedEntries 2025-06-20 11:57:04 -07:00
Frédéric Guillot 6d58052504 fix(readability): do not remove elements within code blocks
`<span class="hljs-comment"># exit 1</span>` will match the `unlikelyCandidatesRegexp` because it contains the `comment` string.
2025-06-19 21:03:53 -07:00
Matthaiks 491d51c95f feat(locale): update Polish translation 2025-06-19 18:47:28 -07:00
Frédéric Guillot db49e41acf refactor(processor): move FilterEntryMaxAgeDays filter to filter package 2025-06-19 17:56:45 -07:00
Frédéric Guillot e6b814199b feat(filter): add EntryDate=max-age:duration filter
Example: `EntryDate=max-age:30d` or `EntryDate=max-age:1h`

Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d".
2025-06-19 17:25:19 -07:00
Frédéric Guillot b0a10f02fd feat(css): add margin-bottom to input for consistent spacing 2025-06-19 16:35:17 -07:00
Frédéric Guillot 9c05c3c493 feat(filter): merge user and feed entry filter rules 2025-06-19 16:24:57 -07:00
Frédéric Guillot 2a9d91c783 feat: add entry filters at the feed level 2025-06-19 15:15:16 -07:00
Frédéric Guillot cb59944d6b refactor(processor): move RewriteEntryURL function to rewrite package 2025-06-19 13:22:29 -07:00
Frédéric Guillot c12476c1a9 refactor(filter): avoid code duplication between IsBlockedEntry and IsAllowedEntry functions 2025-06-19 12:55:00 -07:00
Frédéric Guillot bc6ab44ff2 fix(filter): skip invalid rules instead of exiting the loop 2025-06-19 12:36:35 -07:00
Frédéric Guillot 6282ac1f38 refactor(processor): move filters to a filter package 2025-06-19 12:06:30 -07:00
jvoisin 96c0ef4efd refactor(processor): massive refactoring of filters.go
- Use proper variable names for `key=value` strings parts
- Explicitly assign false to the `match` boolean
- Use an explicit `len(parts) == 2` assertion to help the compiler remove
  `isSliceInBounds` calls.
- Refactor identical code into a containsRegexPattern function.
- Early exit when parsing the first date fails when using the `Between`
  operator, instead of trying to parse the second one.
2025-06-19 11:43:47 -07:00
jvoisin b139ac4a2c refactor(youtube): Remove a regex and make use of fetchWatchTime 2025-06-19 11:43:47 -07:00
jvoisin c818d5bbb8 refactor(youtube): initiliaze two maps to the proper length 2025-06-19 11:43:47 -07:00
jvoisin e366710529 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin 5cff4d7117 refactor(processor): remove a duplication function call
As youtubeVideoID is assigned to getVideoIDFromYouTubeURL(entry.URL),
there is no need to call the latter again when we can simly use youtubeVideoID
instead.
2025-06-19 11:43:47 -07:00
jvoisin f31a784eaa refactor(processor): refactor common code into a fetchWatchTime function
Both nebula and odysee were using the same function to parse time.
2025-06-19 11:43:47 -07:00
jvoisin 7edfcc3cf7 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin fe4b00b9f8 refactor(processor): extract some functions into an utils.go file 2025-06-19 11:43:47 -07:00
jvoisin 46b159ac58 refactor(processor): simplify bilibili processing
- Use strings.Contains instead of a regex
- Use strings concatenation instead of a call to fmt.Sprintf
- Use `any` instead of `interface{}`
2025-06-19 11:43:47 -07:00
jvoisin 86c58e11f6 perf(reader): use a non-cryptographic hash when possible
There is no need to use SHA256 everywhere, especially on small inputs where we
don't care about its cryptographic properties. We're using FNV as it's the
faster available hash in go's standard library, and we're picking its "a"
version as it's slightly better avalanche characteristics, which are
relevant for small inputs.

This commit has the side-effect of invalidating all favicons saved in the
database, which is desirable to benefit from the resize process implemented in
777d0dd2, as it didn't apply retro-actively.

We're also making use of hex.EncodeToString instead of fmt.Sprintf, as it's
marginally faster.

Note that we can't change the usage of sha256 for feed.Hash as it's used to
deduplicate entries in the database.
2025-06-18 20:28:23 -07:00
jvoisin 9a1d9593b3 refactor(crypto): use rand.Text() instead of a custom implementation
Go 1.24 provides the helpful rand.Text() function, returning a base32-encoded
string containing at least 128 bits of randomness. We should make use of it
everywhere it makes sense to do so, if only to not having to think about much
entropy do we need for each cases, and just trust the go crypto team.

Also, rand.Read() can't fail, so no need to check its return value:
https://pkg.go.dev/crypto/rand#Read This behaviour is consistent with go's
standard library itself.
2025-06-18 20:12:55 -07:00
jvoisin 43546976d2 refactor(tests): use b.Loop() instead of for range b.N
See https://tip.golang.org/doc/go1.24#new-benchmark-function
2025-06-18 20:12:55 -07:00
jvoisin 3ab9ca9e4d refactor(http): Don't hardcode TLS configuration
- TLS 1.2 is used as MinVersion by default
- With regard to CipherSuites, in Go 1.22 RSA key exchange based cipher suites
  were removed from the default list, and in Go 1.23 3DES cipher suites were
  removed as well. Ciphers for TLS1.3 aren't configurable.
- No need to specify CurveP25, as the servers will likely disable the weird
  ones like CurveP384 and CurveP521. Removing the explicit specification also
  enables the post-quantum X25519MLKEM768, wow!

I trust the go team to make better choices on the long term than us keeping
miniflux up to date with the latest TLS trend.
2025-06-18 20:12:55 -07:00
jvoisin 1af6df7cb9 perf(api): use math/rand/v2 2025-06-18 20:12:55 -07:00
jvoisin 50dff576b0 build(go): bump to go 1.24 2025-06-18 20:12:55 -07:00
Frédéric Guillot 6af4d69c39 test(sanitizer): add test case to cover Vimeo iframe rewrite without query string 2025-06-17 17:55:39 -07:00
Frédéric Guillot 27015a5e34 test(sanitizer): add unit test for 0x0 pixel tracker 2025-06-17 17:42:55 -07:00
jvoisin cdb57b3843 perf(sanitizer): minor simplifications of the sanitizer
- Factorize some conditions
- Remove useless `default` case and move the return at the end of the functions
- Use strings.CutPrefix instead of strings.HasPrefix + strings.TrimPrefix
- Use switch-case constructs instead of slices.Contains, as this reduces the
  complexity of the functions and allows them to be inlined, as well as helping
  the compiler to optimize them, as it sucks at interprocedural optimizations.
2025-06-17 17:42:45 -07:00
jvoisin 152ef578d2 feat(sanitizer): consider images of size 0x0 as pixel trackers 2025-06-17 17:32:00 -07:00
jvoisin 72486b9bd1 refactor(processor): minor simplification of a loop
This makes the code a tad clearer.
2025-06-17 17:30:13 -07:00
jvoisin 81df0b2a16 perf(rewrite): make getPredefinedRewriteRules O(1) 2025-06-17 17:27:36 -07:00
jvoisin b296f21e98 refactor(internal): add an urllib.DomainWithoutWWW function 2025-06-17 17:27:36 -07:00
jvoisin af15032145 perf(fetcher): pre-allocate the cipherSuites 2025-06-17 16:53:00 -07:00
jvoisin 6a6a88d06d docs(readme): document a couple of nifty features 2025-06-17 16:50:38 -07:00
jvoisin 8660f5e3c7 perf(media): minor regex simplification
The previous regex was using the [ABC..D]*[ABC] pattern, resulting in a lot of
backtracking. The new regex is stopping the matching at the first space or end
of text (and removes the trailing `.` should one be present).

The backtracking was taking around 50% of the CPU time spent in atom.Parse
2025-06-17 16:49:07 -07:00
Frédéric Guillot da4ab4263c feat(rewrite): add parkablogs.com to the referer override list 2025-06-16 20:28:11 -07:00
jvoisin 237672a62c perf(sanitizer): use a switch-case instead of a map
This removes a heap allocation, and should be way faster. It also makes the
code shorted/simpler.
2025-06-16 14:54:48 -07:00
jvoisin e9d4a130fd refactor(sanitizer): remove two useless www. prefixes
No need to have those prefixes, as the check is for substrings, so removing
them will improve the amount of matches.
2025-06-16 14:53:15 -07:00
Frédéric Guillot d291d6a74d refactor(config): remove deprecated config options 2025-06-15 14:42:28 -07:00
Frédéric Guillot e0f7e6f2a8 feat(config)!: remove SERVER_TIMING_HEADER config option
BREAKING CHANGE: This option is not really useful and it's used only on
the unread page.
2025-06-15 14:17:28 -07:00
jvoisin ef3dbd3707 perf(database): use TRUNCATE instead of DELETE FROM in migrations
This is marginally faster.
2025-06-15 13:58:46 -07:00
jvoisin 32f08053aa perf(database): marginally speeds migrations up
PostgreSQL allows table alterations to be done in a single query, so let's take
advantage of it. This should marginally speed the CI up.
2025-06-15 13:58:46 -07:00
jvoisin 117c031f1c feat(integration)!: remove Pocket integration
BREAKING CHANGE: Pocket will no longer be available after July 8, 2025.

https://support.mozilla.org/en-US/kb/future-of-pocket#w_when-is-pocket-shutting-down
2025-06-15 13:29:55 -07:00
Frédéric Guillot b95c9023ee refactor(sanitizer): make isValidAttribute() check O(1) 2025-06-13 21:44:25 -07:00
Frédéric Guillot 3538c4271b refactor(sanitizer): use global variables to avoid recreating slices on every call 2025-06-13 21:34:07 -07:00
Frédéric Guillot ac44507af2 refactor(sanitizer): use a map for iframe allow list 2025-06-13 21:05:23 -07:00
jvoisin 44c48d109f perf(sanitizer): extract a call to url.Parse and make intensive use of it
Previously, url.Parse(baseUrl) was called on every self-closing tags, and on
most opening tags, accounting for around 15% of the CPU time spent in
processor.ProcessFeedEntries
2025-06-13 17:05:17 -07:00
Frédéric Guillot 40727704c2 feat(rewrite): add support for YouTube Shorts video URL pattern 2025-06-12 21:02:46 -07:00
jvoisin 8a014c6abc perf(readability): minor regex improvement
- Improve the check for tags by matching only if its name is followed either by
  a space, a slash or a closing angle
- Use an anonymous group
2025-06-12 19:13:58 -07:00
jvoisin 60ad19c427 perf(rss): early return when looking for an item's author
The `sanitizer.StripTags` function is calling `html.NewTokenizer`, which is
allocating a 4096 bytes buffer on the heap, as well a running a complex state
machine to tokenize html. There is no need to do all of this for empty strings.

This commit also fixes a TrimSpace/StripTags call inversion.
2025-06-11 19:06:15 -07:00
jvoisin f40c1e7f63 fix(reader): fix a crash introduced by d59990f1
And add a fuzzer and a testcase as well to validate that nothing breaks.
2025-06-11 19:04:46 -07:00
Frédéric Guillot a4d16cc5c1 refactor(rewrite): rename Rewriter function to ApplyContentRewriteRules 2025-06-10 20:28:15 -07:00
jvoisin 7c857bdc72 perf(reader): optimize RemoveTrackingParameters
A bit more than 10% of processor.ProcessFeedEntries' CPU time is spent in
urlcleaner.RemoveTrackingParameters, specifically calling url.Parse, so let's
extract this operation outside of it, and do it once before calling
urlcleaner.RemoveTrackingParameters multiple times.

Co-authored-by: Frédéric Guillot <f@miniflux.net>
2025-06-10 19:29:25 -07:00
jvoisin 0caadf82f2 perf(rss): optimize a bit BuildFeed
Calls to urllib.AbsoluteURL take a bit less than 10% of the time spent in
parser.ParseFeed, completely parsing an url only to check if it's absolute, and
if not, to make it so.

Checking if it starts with `https://` or `http://` is usually enough to find if
an url is absolute, and if is doesn't, it's always possible to fall back to
urllib.AbsoluteURL.

This also comes with the advantage of reducing heap allocations, as most of the
time spent in urllib.AbsoluteURL is heap-related (de)allocations.
2025-06-10 19:23:16 -07:00
jvoisin 0086e0b356 perf(validator): slightly optimize a regex
- There is no need to have groups as we're only using this regex for
  `MatchString`.
- Since the only place where this regex is used is already calling
  strings.ToLower, there is no need to check for `A-Z`.
2025-06-10 19:20:58 -07:00
Frédéric Guillot 70b513b8db feat(ui): display external URL in single entry view
Display the article's external URL directly in the single entry view.

Rationale: On mobile devices, users couldn't see where a link pointed before tapping it.
Previously, the only way to view the external URL was by hovering - an action not available on touch devices.
2025-06-09 21:14:55 -07:00
Frédéric Guillot cecc18420d feat(sanitizer): add validation for empty width and height attributes in img tags 2025-06-09 20:38:17 -07:00
Frédéric Guillot d53fd17e10 feat(sanitizer): validate MathML XML namespace 2025-06-09 20:28:54 -07:00
Frédéric Guillot 21d22d7f0b feat(sanitizer): add support for fetchpriority and decoding attributes in img tags 2025-06-09 20:12:15 -07:00
jvoisin d59990f1dd perf(xml): optimize xml filtering
Instead of using bytes.Map which is returning a copy of the provided []byte,
use a custom in-place implementation, as the bytes.Map call is taking around
25% of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 49085daefe perf(xml): optimized NewXMLDecoder
io.ReadAll is growing the underlying buffer progressively, while
io.Copy is able to allocate it in one go, which is significantly faster.
io.ReadAll is currently accounting for around 10% of the CPU time of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 5872710d22 perf(storage): optimize away two Sprintf calls
The call to fmt.Sprintf in WithFeedID accounts for more than 20% of the time
spent in GetFeed. Use strconv.Itoa instead, as it's much much faster.
Also change WithCategoryID in the same way, for consistency's sake.
2025-06-09 13:10:51 -07:00
Qeynos d2212dee12 feat(locale): update Chinese translations 2025-06-08 21:11:04 -07:00
Frédéric Guillot 8db637cb39 feat(ui): add user setting to control target="_blank" on links
Rationale: Opening links in the current tab is the default browser behavior.

Using `target="_blank"` on external links can lead to accessibility issues and override user preferences. It may also interfere with assistive technologies and expected browser behavior.

To maintain backward compatibility, this option is enabled by default (`true`), which adds `target="_blank"` to links.
2025-06-08 21:07:11 -07:00
Frédéric Guillot 699deea72c feat(oidc): use preferred_username first instead of email claim 2025-06-08 18:05:47 -07:00
Frédéric Guillot c41d189a7a fix(karakeep): correct method name and improve error handling in SaveURL 2025-06-08 17:47:20 -07:00
Frédéric Guillot adfc38d237 feat(locale): update locales using machine translation 2025-06-08 17:14:45 -07:00
Frédéric Guillot a8bb7a48d7 feat(ui): avoid showing an excessive number of tags 2025-06-08 15:29:09 -07:00
jvoisin f9dce3d10f perf(timzone): cache getLocation's results
Every time getLocation is called, it's opening and parsing a file on disc,
sometimes a zip file depending on the system. We can cache the results instead
of doing this.

See https://github.com/golang/go/issues/24844 and https://github.com/golang/go/issues/26106
2025-06-08 13:50:18 -07:00
dependabot[bot] 567e8cfc89 build(deps): bump golang.org/x/image from 0.27.0 to 0.28.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/image/compare/v0.27.0...v0.28.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:09:51 -07:00
dependabot[bot] a8e73d6875 build(deps): bump golang.org/x/net from 0.40.0 to 0.41.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:04:04 -07:00
dependabot[bot] fa7f2b18a0 build(deps): bump golang.org/x/crypto from 0.38.0 to 0.39.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 17:48:47 -07:00
Jesse Jaggars 43d302e768 feat: adding support for saving entries to karakeep
Signed-off-by: Jesse Jaggars <jhjaggars@gmail.com>
2025-06-04 21:10:26 -07:00
dependabot[bot] 4e181330d0 build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.21 to 3.22.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 19:17:39 -07:00
the7thNightmare 0369f03940 feat(locale): update Indonesian translations 2025-05-28 20:45:45 -07:00
Qeynos 4597d9b289 feat(locale): update Chinese translations 2025-05-28 20:44:40 -07:00
Cthulhux 7bfd22aab7 feat(locale): update German translation
Translated one string, found a good wording for the other.
2025-05-27 19:17:23 -07:00
Frédéric Guillot 325c505b88 docs(changelog): update release notes for version 2.2.9 2025-05-26 18:13:05 -07:00
Frédéric Guillot bfd8860398 feat(api): add new endpoints to manage API keys 2025-05-25 15:50:13 -07:00
Matthaiks ebd65da3b6 feat(locale): update Polish translation 2025-05-25 15:30:36 -07:00
Frédéric Guillot 83191b0c1d fix(storage): remove extra comma introduced by commit 09fb05a 2025-05-25 13:33:41 -07:00
Frédéric Guillot 8142268799 feat: populate feed description automatically 2025-05-24 21:15:52 -07:00
Frédéric Guillot 5920e02562 feat: add liveness and readiness probes
- Added new routes: /liveness, /healthz, /readiness, /readyz
- These routes do not take the base path into consideration and are always available at the root of the server
2025-05-24 20:36:05 -07:00
Kelly Norton 09fb05aaaf feat: add option to always open articles externally 2025-05-24 19:46:01 -07:00
Frédéric Guillot 52b184394f fix(migrations): prevent failure at v45 with long entry URLs
Fixes an issue where upgrading from older versions of Miniflux could fail with the following PostgreSQL error:

```
[FATAL] [Migration v45] pq: index row size 2744 exceeds btree version 4 maximum 2704 for index "entries_feed_url_idx"
```
2025-05-23 13:27:05 -07:00
Matthaiks 7c8c7c2711 feat(locale): update Polish translation 2025-05-23 12:21:28 -07:00
Frédéric Guillot 9768eb9fb9 feat(locale): update French translations 2025-05-22 20:28:38 -07:00
Tianzhi Jin b65373db7e feat(webauthn): perfer creation of a client-side discoverable credential 2025-05-22 20:14:00 -07:00
dependabot[bot] 596d22c02c build(deps): bump github.com/tdewolff/minify/v2 from 2.23.6 to 2.23.8
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.6 to 2.23.8.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.6...v2.23.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 19:32:06 -07:00
Anton Larionov 4b86570b7c chore(gitignore): ignore miniflux binary in root directory 2025-05-22 19:31:52 -07:00
Anton Larionov e99864a456 fix(locale): localize Git commit label at about page 2025-05-22 19:30:10 -07:00
Anton Larionov 225463817c feat(locale): complete Russian translation 2025-05-20 19:37:41 -07:00
Matthaiks 3db6e822cb feat(locale): update Polish translation 2025-05-20 19:36:44 -07:00
dependabot[bot] 1c19151925 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.5 to 2.23.6
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.5 to 2.23.6.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.5...v2.23.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-20 19:36:20 -07:00
Anton Larionov 553c578f2e feat(rssbridge): support auth token for RSS-Bridge 2025-05-19 20:47:12 -07:00
Tianzhi Jin 81ec32a8b6 fix(webauthn): correct arg in debug log 2025-05-14 21:01:52 -07:00
dependabot[bot] 3818a8a4fb build(deps): bump github.com/go-webauthn/webauthn from 0.12.3 to 0.13.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.3 to 0.13.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-13 19:17:05 -07:00
Frédéric Guillot 036704b3e4 feat(response): change error response content type to plain text and escape HTML
Adding another layer of security in addition to the existing CSP cannot
hurt.
2025-05-11 19:15:54 -07:00
Frédéric Guillot 327d027d38 feat(settings): replace div.panel with paragraph tags for OAuth2 links 2025-05-11 18:06:16 -07:00
Frédéric Guillot 5ae2cbd943 feat(settings): add validation for entry order and categories sorting order 2025-05-11 17:52:59 -07:00
dependabot[bot] f15d29deb3 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.3 to 2.23.5
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.3 to 2.23.5.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.3...v2.23.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-08 19:03:12 -07:00
Frédéric Guillot 828a4334db fix(sanitizer): MathML tags are not fully supported by golang.org/x/net/html
See https://github.com/golang/net/blob/master/html/atom/gen.go
and https://github.com/golang/net/blob/master/html/atom/table.go
2025-05-06 21:18:19 -07:00
jvoisin d1dc369bb2 feat(sanitizer): add MathML tags to the sanitizer
This was found by reading the article pointed by https://lobste.rs/s/nobvmp/how_prime_factorizations_govern_collatz
2025-05-06 20:19:56 -07:00
Frédéric Guillot a8076e1891 ci: remove deprecated reviewers field from dependantbot.yml 2025-05-06 20:17:19 -07:00
dependabot[bot] 3448d6267c build(deps): bump golang.org/x/net from 0.39.0 to 0.40.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:10:57 -07:00
dependabot[bot] 159261f2f8 build(deps): bump golang.org/x/oauth2 from 0.29.0 to 0.30.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:07:15 -07:00
jvoisin ff2dfe977b feat: remove the ref parameter from url
This is used by (at least) Ghost (https://forum.ghost.org/t/ref-parameter-being-added-to-links/38335)

Examples:
- https://blog.exploits.club/exploits-club-weekly-newsletter-66-mitigations-galore-dirtycow-revisited-program-analysis-for-uafs-and-more/
- https://labs.watchtowr.com/is-the-sofistication-in-the-room-with-us-x-forwarded-for-and-ivanti-connect-secure-cve-2025-22457/
2025-05-06 19:59:55 -07:00
dependabot[bot] a5e3719773 build(deps): bump golang.org/x/crypto from 0.37.0 to 0.38.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:32:46 -07:00
dependabot[bot] cdadb87203 build(deps): bump golang.org/x/image from 0.26.0 to 0.27.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/image/compare/v0.26.0...v0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:28:02 -07:00
dependabot[bot] 5284d61fe3 build(deps): bump golangci/golangci-lint-action from 7 to 8
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:25:14 -07:00
Frédéric Guillot 3de9629a49 feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler 2025-05-04 20:38:53 -07:00
Frédéric Guillot 8d821dfc3b fix(googlereader): handle various item ID formats
- Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
- NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
- Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
- Liferea uses this format: "12345" (decimal string)
2025-05-04 20:11:37 -07:00
Frédéric Guillot cb775bc79e refactor(googlereader): move constants to separate files 2025-05-04 13:02:54 -07:00
Frédéric Guillot 6cc8d8abf1 fix(googlereader): /items/contents should accept short form item IDs 2025-05-03 21:48:41 -07:00
Frédéric Guillot 50395f13ca feat(googlereader): add mark-all-as-read endpoint 2025-05-03 18:38:54 -07:00
Frédéric Guillot e8c3435bb9 fix(googlereader): return a 400 instead of 500 for invalid edit requests 2025-05-02 18:15:00 -07:00
Frédéric Guillot 9a8a8bdca3 refactor(googlreader): remove redundant log message 2025-05-02 17:56:21 -07:00
Frédéric Guillot 63f0a17388 fix(googlereader): avoid panic for inexisting feed or category 2025-05-02 17:42:25 -07:00
NoelNegash 81c7669945 feat(sanitized): allow Spotify iframes 2025-05-02 16:25:17 -07:00
dependabot[bot] 2b000d1022 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.2 to 2.23.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.2 to 2.23.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.2...v2.23.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-02 16:20:29 -07:00
dependabot[bot] 27253c8a97 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.1 to 2.23.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.1 to 2.23.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.1...v2.23.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 16:54:23 -07:00
Frédéric Guillot f68046cce0 docs(changelog): update release notes for version 2.2.8 2025-04-22 21:01:43 -07:00
Frédéric Guillot d33e305af9 fix(api): hide_globally categories field should be a boolean 2025-04-21 19:43:25 -07:00
Frédéric Guillot 764212f37c refactor(js): replace DomHelper methods with standalone functions 2025-04-17 18:15:08 -07:00
Tali Auster e02b65d4bc fix: deal with navigator.share exceptions
Navigator.share returns a promise that's executed in the background, but
unless we await it explicitly, we won't get the exceptions in the
try/catch block.
2025-04-17 17:07:38 -07:00
Tali Auster fe7ec25a09 chore: fix indentation 2025-04-17 17:07:38 -07:00
Tali Auster 2959a4d2bf fix: clarify share flow in UI
Prior to this commit, to share an entry, a user has to click on the
share link and then copy the URL they are redirected to. The danger is
that they may right-click and copy the share link without actually
clicking on it, and therefore share a link that, when authenticated,
shares the entry, rather than actually sharing the entry.

Here, we avoid this misinterpretation by making sharing into a POST
request and using a form rather than a link.
2025-04-17 17:07:38 -07:00
AiraNadih 6b70a7dc81 feat(api): add update_content query parameter to /entries/{entryID}/fetch-content endpoint 2025-04-17 12:41:58 -07:00
dependabot[bot] 495c6aacd9 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.27 to 1.14.28
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.27 to 1.14.28.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.27...v1.14.28)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-16 19:27:11 -07:00
Frédéric Guillot bc228d0afe ci: add documentation issue template 2025-04-12 14:29:52 -07:00
Frédéric Guillot d139d8a6ce feat(cli): add -reset-feed-next-check-at argument 2025-04-11 15:56:57 -07:00
dependabot[bot] 28d0185e79 build(deps): bump github.com/PuerkitoBio/goquery from 1.10.2 to 1.10.3
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.10.2 to 1.10.3.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.10.2...v1.10.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-11 15:44:47 -07:00
Frédéric Guillot c87c93d85f feat(config): add SCHEDULER_ROUND_ROBIN_MAX_INTERVAL option
Add option to cap maximum refresh interval when RSS TTL, Retry-After, Cache-Control, or Expires headers specify excessively high values.
2025-04-11 15:40:32 -07:00
dependabot[bot] 0ef21e85c2 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.1 to 1.22.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.21.1...v1.22.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 20:53:15 -07:00
dependabot[bot] 7438d67061 build(deps): bump github.com/tdewolff/minify/v2 from 2.22.4 to 2.23.1
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.22.4 to 2.23.1.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.22.4...v2.23.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:49:12 -07:00
dependabot[bot] e4399b4f9a build(deps): bump golang.org/x/image from 0.25.0 to 0.26.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/image/compare/v0.25.0...v0.26.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:48:54 -07:00
dependabot[bot] b4f891705a build(deps): bump golang.org/x/oauth2 from 0.28.0 to 0.29.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.28.0...v0.29.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 20:10:31 -07:00
dependabot[bot] 3d72404ace build(deps): bump golang.org/x/net from 0.38.0 to 0.39.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 19:55:08 -07:00
Matthaiks c4e92e6111 feat(locale): update Polish translation 2025-04-07 12:06:44 -07:00
Frédéric Guillot ef22e95f8b feat: implement proxy URL per feed 2025-04-06 21:05:19 -07:00
tssujt 7b344de846 feat(telegrambot): replace "Go to website" button with "Go to Miniflux" 2025-04-06 18:30:42 -07:00
Frédéric Guillot c45b51d1f8 feat: use Cache-Control max-age and Expires headers to calculate next check 2025-04-06 16:24:00 -07:00
Frédéric Guillot 0af1a6e121 refactor: avoid logging twice the feed errors in the background worker 2025-04-06 15:39:40 -07:00
Frédéric Guillot 535fd050b7 feat: add proxy rotation functionality 2025-04-06 14:59:00 -07:00
Frédéric Guillot d20e8a4e2c ci(linter): replace commitlint with a Python script 2025-04-05 20:41:34 -07:00
Qeynos 7514e8a0c1 feat(locale): update Chinese translation 2025-04-04 19:40:44 -07:00
Cthulhux ca3ede3183 Update de_DE.json
More translations
2025-04-04 19:37:53 -07:00
dependabot[bot] 038d33600a build(deps): bump github.com/coreos/go-oidc/v3 from 3.13.0 to 3.14.1
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.13.0 to 3.14.1.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.13.0...v3.14.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-03 17:34:17 -07:00
dependabot[bot] 1a3a174688 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.25 to 1.14.27
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.25 to 1.14.27.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.25...v1.14.27)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-02 19:25:34 -07:00
dependabot[bot] 6848c759b5 build(deps): bump github.com/mattn/go-sqlite3 from 1.14.24 to 1.14.25
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.24 to 1.14.25.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.24...v1.14.25)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 19:46:53 -07:00
dependabot[bot] 10578f512f build(deps): bump github.com/go-webauthn/webauthn from 0.12.2 to 0.12.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.2 to 0.12.3.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.2...v0.12.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-01 19:46:40 -07:00
412 changed files with 28249 additions and 14253 deletions
+4 -2
View File
@@ -9,7 +9,9 @@
],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"customizations": {
"vscode": {
@@ -28,4 +30,4 @@
]
}
}
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go:1.23
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
volumes:
- ..:/workspace:cached
command: sleep infinity
@@ -11,10 +10,10 @@ services:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:15
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
hostname: postgres
environment:
POSTGRES_DB: miniflux2
+81
View File
@@ -0,0 +1,81 @@
name: "Documentation Issue"
description: "Report issues or suggest improvements for the documentation"
title: "[Docs]: "
type: "Documentation"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.
- type: dropdown
id: issue_type
attributes:
label: "Documentation Issue Type"
description: "What kind of documentation issue are you reporting?"
options:
- "Missing Information"
- "Incorrect Information"
- "Outdated Information"
- "Unclear Explanation"
- "Formatting/Structural Issue"
- "Typo/Grammar Error"
- "Documentation Request"
- "Other"
validations:
required: true
- type: input
id: summary
attributes:
label: "Summary"
description: "Briefly describe the documentation issue."
placeholder: "e.g., The API authentication section is outdated"
validations:
required: true
- type: input
id: location
attributes:
label: "Location"
description: "Where is the documentation you're referring to? Provide URLs, file paths, or section names."
placeholder: "e.g., README.md, docs/api.md, Installation section of the website"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Detailed Description"
description: "Provide a detailed description of the issue or improvement."
placeholder: "e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5."
validations:
required: true
- type: textarea
id: current_content
attributes:
label: "Current Content (if applicable)"
description: "What does the current documentation say?"
placeholder: "Paste the current documentation text here."
validations:
required: false
- type: textarea
id: suggested_content
attributes:
label: "Suggested Changes"
description: "If you have specific suggestions for how to improve the documentation, please provide them here."
placeholder: "e.g., Add a new section about token-based authentication with these details..."
validations:
required: false
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux does this documentation issue relate to?"
placeholder: "e.g., 2.2.6, or 'all versions'"
validations:
required: false
@@ -63,3 +63,5 @@ body:
required: true
- label: "I understand that feature requests are not guaranteed to be implemented."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
+2
View File
@@ -84,3 +84,5 @@ body:
required: true
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
-24
View File
@@ -4,52 +4,28 @@ updates:
directory: "/"
schedule:
interval: "daily"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
+1 -1
View File
@@ -4,4 +4,4 @@ Have you followed these guidelines?
- [ ] There are no breaking changes
- [ ] I have thoroughly tested my changes and verified there are no regressions
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
+6 -4
View File
@@ -1,4 +1,6 @@
name: Build Binaries
permissions:
contents: read
on:
workflow_dispatch:
push:
@@ -10,18 +12,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Golang
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.24.x"
go-version: stable
check-latest: true
- name: Compile binaries
env:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: binaries
path: miniflux-*
+25
View File
@@ -0,0 +1,25 @@
name: Mirror to Codeberg
on:
push:
branches: [ main ]
delete:
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Mirror to Codeberg
env:
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
run: |
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
git push --force --prune codeberg \
"refs/heads/*:refs/heads/*" \
"refs/tags/*:refs/tags/*"
+17 -7
View File
@@ -9,6 +9,7 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
@@ -16,12 +17,14 @@ on:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '45 22 * * 3'
workflow_dispatch:
jobs:
analyze:
name: Analyze
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
@@ -30,20 +33,27 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
if: matrix.language == 'go'
with:
go-version: "1.24.x"
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
@@ -38,7 +38,7 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
@@ -53,7 +53,7 @@ jobs:
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: packages
path: "*.deb"
@@ -64,7 +64,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up QEMU
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+11 -19
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,33 +25,25 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.24.x"
- uses: golangci/golangci-lint-action@v7
with:
args: >
--timeout 10m
--disable errcheck
--enable sqlclosecheck,misspell,whitespace,gocritic
go-version: stable
- uses: golangci/golangci-lint-action@v9
- name: Run gofmt linter
run: gofmt -d -e .
commitlint:
if: github.event_name == 'pull_request'
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
node-version: "lts/*"
- name: Install commitlint
run: |
npm install --save-dev @commitlint/config-conventional @commitlint/cli
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
python-version: '3.13'
- name: Validate PR commits
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
+6 -5
View File
@@ -11,17 +11,18 @@ on:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
run: make rpm VERSION=2.2.x_dev
- name: List generated files
run: ls -l *.rpm
build-package-manually:
@@ -29,13 +30,13 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: packages
path: "*.rpm"
@@ -46,7 +47,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build RPM Package
@@ -0,0 +1,83 @@
import subprocess
import re
import sys
import argparse
from typing import Match
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
def get_commit_message(commit_hash: str) -> str:
"""Get the commit message for a given commit hash."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "show", "-s", "--format=%B", commit_hash],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error retrieving commit message: {e}")
sys.exit(1)
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
"""Check if commit message follows conventional commit format."""
first_line: str = message.split("\n")[0]
match: Match[str] | None = re.match(pattern, first_line)
return bool(match)
def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
"""Check all commits in a range for compliance."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "log", "--format=%H", f"{base_ref}..{head_ref}"],
capture_output=True,
text=True,
check=True,
)
commit_hashes: list[str] = result.stdout.strip().split("\n")
# Filter out empty lines
commit_hashes = [hash for hash in commit_hashes if hash]
non_compliant: list[dict[str, str]] = []
for commit_hash in commit_hashes:
message: str = get_commit_message(commit_hash)
if not check_commit_message(message):
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
return non_compliant
except subprocess.CalledProcessError as e:
print(f"Error checking commit range: {e}")
sys.exit(1)
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
args: argparse.Namespace = parser.parse_args()
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
if non_compliant:
print("The following commits do not follow the conventional commit format:")
for commit in non_compliant:
print(f"- {commit['hash'][:8]}: {commit['message']}")
print("\nPlease ensure your commit messages follow the format:")
print("type(scope): subject")
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
sys.exit(1)
else:
print("All commits follow the conventional commit format!")
sys.exit(0)
if __name__ == "__main__":
main()
+6 -8
View File
@@ -15,14 +15,13 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.24.x"]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
go-version: stable
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
@@ -45,16 +44,15 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.24.x"
go-version: stable
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PGHOST: 127.0.0.1
PGPASSWORD: postgres
+2 -2
View File
@@ -1,7 +1,7 @@
./*.sha256
./miniflux
/miniflux
.idea
.vscode
*.deb
*.rpm
miniflux-*
miniflux-*
+23
View File
@@ -0,0 +1,23 @@
version: "2"
linters:
default: standard
disable:
- errcheck
enable:
- errname
- gocritic
- goheader
- loggercheck
- misspell
- perfsprint
- prealloc
- sqlclosecheck
- staticcheck
- whitespace
settings:
loggercheck:
slog: true
goheader:
template: |-
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
SPDX-License-Identifier: Apache-2.0
+178
View File
@@ -0,0 +1,178 @@
# Contributing to Miniflux
This document outlines how to contribute effectively to Miniflux.
## Philosophy
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
- **Improving existing features takes priority over adding new ones**
- **Quality over quantity** - well-implemented, focused features are preferred
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
## Before You Start
### Feature Requests
Before implementing a new feature:
- Check if it aligns with Miniflux's philosophy
- Consider if the feature could be implemented differently to maintain simplicity
- Remember that developing software takes significant time, and this is a volunteer-driven project
- If you need a specific feature, the best approach is to contribute it yourself
### Bug Reports
When reporting bugs:
- Search existing issues first to avoid duplicates
- Provide clear reproduction steps
- Include relevant system information (OS, browser, Miniflux version)
- Include error messages, screenshots, and logs when applicable
## Development Setup
### Requirements
- **Git**
- **Go >= 1.24**
- **PostgreSQL**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
4. **Run locally in debug mode:**
```bash
make run
```
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
## Development Workflow
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
```
### Building
- **Current platform:** `make miniflux`
- **All platforms:** `make build`
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
- **Docker image:** `make docker-image`
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
## Pull Request Guidelines
### What Is Preferred
✅ **Good Pull Requests:**
- Focus on a single issue or feature
- Include tests for new functionality
- Maintain or improve performance
- Follow existing code style and patterns
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
- Update documentation when necessary
### What to Avoid
❌ **Pull Requests That Cannot Be Accepted:**
- **Too many changes** - makes review difficult
- **Breaking changes** - disrupts existing functionality
- **New bugs or regressions** - reduces software quality
- **Unnecessary dependencies** - conflicts with minimalist approach
- **Performance degradation** - slows down the software
- **Poor-quality code** - hard to maintain
- **Dependent PRs** - creates review complexity
- **Radical UI changes** - disrupts user experience
- **Conflicts with philosophy** - doesn't align with minimalist approach
### Pull Request Template
When creating a pull request, please include:
- **Description:** What does this PR do?
- **Motivation:** Why is this change needed?
- **Testing:** How was this tested?
- **Breaking Changes:** Are there any breaking changes?
- **Related Issues:** Link to any related issues
## Code Style
- Follow Go conventions and best practices
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
- Write clear, descriptive variable and function names
- Include comments for complex logic
- Keep functions small and focused
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves
## Communication
- **Discussions:** Use GitHub Discussions for general questions and community interaction
- **Issues:** Use GitHub issues for bug reports and feature requests
- **Pull Requests:** Use PR comments for code-specific discussions
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
## Questions?
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
- Look at existing issues and pull requests for examples
-1917
View File
File diff suppressed because it is too large Load Diff
+19 -52
View File
@@ -1,9 +1,7 @@
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
@@ -22,16 +20,12 @@ export PGPASSWORD := postgres
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
freebsd-x86 \
openbsd-amd64 \
openbsd-x86 \
netbsd-x86 \
netbsd-amd64 \
windows-amd64 \
windows-x86 \
build \
run \
clean \
add-string \
test \
lint \
integration-test \
@@ -44,71 +38,48 @@ export PGPASSWORD := postgres
debian-packages
miniflux:
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
# NOTE: unsupported targets
netbsd-amd64:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-x86:
@ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
freebsd-x86:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
netbsd-x86:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
openbsd-x86:
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@@ -116,7 +87,6 @@ run:
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
.PHONY: add-string
add-string:
cd internal/locale/translations && \
for file in *.json; do \
@@ -125,27 +95,25 @@ add-string:
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
lint:
go vet ./...
staticcheck ./...
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
gofmt -d -e .
golangci-lint run
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
DEBUG=1 \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
LOG_LEVEL=debug \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
@@ -157,7 +125,6 @@ integration-test:
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ rm miniflux-test
@ psql -U postgres -c 'drop database if exists miniflux_test;'
docker-image:
+8 -4
View File
@@ -22,7 +22,7 @@ Features
- Provides full-text search (powered by Postgres).
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy
### Privacy and Security
- Removes pixel trackers.
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
@@ -33,6 +33,8 @@ Features
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
- Blocks external JavaScript to prevent tracking and enhance security.
- Sanitizes external content before rendering it.
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
### Bot Protection Bypass Mechanisms
@@ -70,7 +72,7 @@ Features
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pocket](https://getpocket.com/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
@@ -97,13 +99,15 @@ Features
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
- Updates feeds in the background using an internal scheduler or a traditional cron job.
- Sanitizes external content before rendering it.
- Enforces a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that permits only application JavaScript and blocks inline scripts and styles.
- Uses native lazy loading for images and iframes.
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
+551 -57
View File
@@ -4,9 +4,11 @@
package client // import "miniflux.app/v2/client"
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
@@ -26,22 +28,45 @@ func New(endpoint string, credentials ...string) *Client {
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
switch len(credentials) {
case 2:
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
case 1:
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
default:
return NewClientWithOptions(endpoint)
}
}
// NewClientWithOptions returns a new Miniflux client with options.
func NewClientWithOptions(endpoint string, options ...Option) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
switch len(credentials) {
case 2:
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
case 1:
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
default:
return &Client{request: &request{endpoint: endpoint}}
request := &request{endpoint: endpoint, client: http.DefaultClient}
for _, option := range options {
option(request)
}
return &Client{request: request}
}
func withDefaultTimeout() (context.Context, func()) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
return ctx, cancel
}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
body, err := c.request.Get("/healthcheck")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.HealthcheckContext(ctx)
}
// HealthcheckContext checks if the application is up and running.
func (c *Client) HealthcheckContext(ctx context.Context) error {
body, err := c.request.Get(ctx, "/healthcheck")
if err != nil {
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
}
@@ -61,7 +86,14 @@ func (c *Client) Healthcheck() error {
// Version returns the version of the Miniflux instance.
func (c *Client) Version() (*VersionResponse, error) {
body, err := c.request.Get("/v1/version")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.VersionContext(ctx)
}
// VersionContext returns the version of the Miniflux instance.
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
body, err := c.request.Get(ctx, "/v1/version")
if err != nil {
return nil, err
}
@@ -77,7 +109,14 @@ func (c *Client) Version() (*VersionResponse, error) {
// Me returns the logged user information.
func (c *Client) Me() (*User, error) {
body, err := c.request.Get("/v1/me")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MeContext(ctx)
}
// MeContext returns the logged user information.
func (c *Client) MeContext(ctx context.Context) (*User, error) {
body, err := c.request.Get(ctx, "/v1/me")
if err != nil {
return nil, err
}
@@ -93,7 +132,14 @@ func (c *Client) Me() (*User, error) {
// Users returns all users.
func (c *Client) Users() (Users, error) {
body, err := c.request.Get("/v1/users")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UsersContext(ctx)
}
// UsersContext returns all users.
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
body, err := c.request.Get(ctx, "/v1/users")
if err != nil {
return nil, err
}
@@ -109,7 +155,14 @@ func (c *Client) Users() (Users, error) {
// UserByID returns a single user.
func (c *Client) UserByID(userID int64) (*User, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/users/%d", userID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByIDContext(ctx, userID)
}
// UserByIDContext returns a single user.
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
if err != nil {
return nil, err
}
@@ -125,7 +178,14 @@ func (c *Client) UserByID(userID int64) (*User, error) {
// UserByUsername returns a single user.
func (c *Client) UserByUsername(username string) (*User, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/users/%s", username))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByUsernameContext(ctx, username)
}
// UserByUsernameContext returns a single user.
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
body, err := c.request.Get(ctx, "/v1/users/"+username)
if err != nil {
return nil, err
}
@@ -141,7 +201,14 @@ func (c *Client) UserByUsername(username string) (*User, error) {
// CreateUser creates a new user in the system.
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
body, err := c.request.Post("/v1/users", &UserCreationRequest{
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateUserContext(ctx, username, password, isAdmin)
}
// CreateUserContext creates a new user in the system.
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
Username: username,
Password: password,
IsAdmin: isAdmin,
@@ -161,7 +228,14 @@ func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, err
// UpdateUser updates a user in the system.
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateUserContext(ctx, userID, userChanges)
}
// UpdateUserContext updates a user in the system.
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
if err != nil {
return nil, err
}
@@ -177,18 +251,99 @@ func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest)
// DeleteUser removes a user from the system.
func (c *Client) DeleteUser(userID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteUserContext(ctx, userID)
}
// DeleteUserContext removes a user from the system.
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
}
// APIKeys returns all API keys for the authenticated user.
func (c *Client) APIKeys() (APIKeys, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.APIKeysContext(ctx)
}
// APIKeysContext returns all API keys for the authenticated user.
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
body, err := c.request.Get(ctx, "/v1/api-keys")
if err != nil {
return nil, err
}
defer body.Close()
var apiKeys APIKeys
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKeys, nil
}
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateAPIKeyContext(ctx, description)
}
// CreateAPIKeyContext creates a new API key for the authenticated user.
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
body, err := c.request.Post(ctx, "/v1/api-keys", &APIKeyCreationRequest{
Description: description,
})
if err != nil {
return nil, err
}
defer body.Close()
var apiKey *APIKey
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKey, nil
}
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteAPIKeyContext(ctx, apiKeyID)
}
// DeleteAPIKeyContext removes an API key for the authenticated user.
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkAllAsReadContext(ctx, userID)
}
// MarkAllAsReadContext marks all unread entries as read for a given user.
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatus() (bool, error) {
body, err := c.request.Get("/v1/integrations/status")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IntegrationsStatusContext(ctx)
}
// IntegrationsStatusContext fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
body, err := c.request.Get(ctx, "/v1/integrations/status")
if err != nil {
return false, err
}
@@ -207,7 +362,14 @@ func (c *Client) IntegrationsStatus() (bool, error) {
// Discover try to find subscriptions from a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DiscoverContext(ctx, url)
}
// DiscoverContext tries to find subscriptions from a website.
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
if err != nil {
return nil, err
}
@@ -223,7 +385,37 @@ func (c *Client) Discover(url string) (Subscriptions, error) {
// Categories gets the list of categories.
func (c *Client) Categories() (Categories, error) {
body, err := c.request.Get("/v1/categories")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesContext(ctx)
}
// CategoriesContext gets the list of categories.
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCounters() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesWithCountersContext(ctx)
}
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
if err != nil {
return nil, err
}
@@ -239,8 +431,15 @@ func (c *Client) Categories() (Categories, error) {
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
body, err := c.request.Post("/v1/categories", map[string]interface{}{
"title": title,
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryContext(ctx, title)
}
// CreateCategoryContext creates a new category.
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
Title: title,
})
if err != nil {
return nil, err
@@ -255,10 +454,39 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
return category, nil
}
// CreateCategoryWithOptions creates a new category with options.
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
}
// CreateCategoryWithOptionsContext creates a new category with options.
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", createRequest)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategory updates a category.
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), map[string]interface{}{
"title": title,
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryContext(ctx, categoryID, title)
}
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: SetOptionalField(title),
})
if err != nil {
return nil, err
@@ -273,15 +501,52 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
return category, nil
}
// UpdateCategoryWithOptions updates a category with options.
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
}
// UpdateCategoryWithOptionsContext updates a category with options.
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// MarkCategoryAsRead marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkCategoryAsReadContext(ctx, categoryID)
}
// MarkCategoryAsReadContext marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
return err
}
// CategoryFeeds gets feeds of a category.
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryFeedsContext(ctx, categoryID)
}
// CategoryFeedsContext gets feeds of a category.
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
if err != nil {
return nil, err
}
@@ -297,18 +562,39 @@ func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
// DeleteCategory removes a category.
func (c *Client) DeleteCategory(categoryID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteCategoryContext(ctx, categoryID)
}
// DeleteCategoryContext removes a category.
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
}
// RefreshCategory refreshes a category.
func (c *Client) RefreshCategory(categoryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshCategoryContext(ctx, categoryID)
}
// RefreshCategoryContext refreshes a category.
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
return err
}
// Feeds gets all feeds.
func (c *Client) Feeds() (Feeds, error) {
body, err := c.request.Get("/v1/feeds")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedsContext(ctx)
}
// FeedsContext gets all feeds.
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
body, err := c.request.Get(ctx, "/v1/feeds")
if err != nil {
return nil, err
}
@@ -324,7 +610,14 @@ func (c *Client) Feeds() (Feeds, error) {
// Export creates OPML file.
func (c *Client) Export() ([]byte, error) {
body, err := c.request.Get("/v1/export")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ExportContext(ctx)
}
// ExportContext creates OPML file.
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
body, err := c.request.Get(ctx, "/v1/export")
if err != nil {
return nil, err
}
@@ -340,13 +633,27 @@ func (c *Client) Export() ([]byte, error) {
// Import imports an OPML file.
func (c *Client) Import(f io.ReadCloser) error {
_, err := c.request.PostFile("/v1/import", f)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ImportContext(ctx, f)
}
// ImportContext imports an OPML file.
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
_, err := c.request.PostFile(ctx, "/v1/import", f)
return err
}
// Feed gets a feed.
func (c *Client) Feed(feedID int64) (*Feed, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d", feedID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedContext(ctx, feedID)
}
// FeedContext gets a feed.
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
if err != nil {
return nil, err
}
@@ -362,7 +669,14 @@ func (c *Client) Feed(feedID int64) (*Feed, error) {
// CreateFeed creates a new feed.
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
body, err := c.request.Post("/v1/feeds", feedCreationRequest)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateFeedContext(ctx, feedCreationRequest)
}
// CreateFeedContext creates a new feed.
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
if err != nil {
return 0, err
}
@@ -382,7 +696,14 @@ func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, er
// UpdateFeed updates a feed.
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateFeedContext(ctx, feedID, feedChanges)
}
// UpdateFeedContext updates a feed.
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
if err != nil {
return nil, err
}
@@ -396,32 +717,93 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest)
return f, nil
}
// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()
var response struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}
return response.ID, nil
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkFeedAsReadContext(ctx, feedID)
}
// MarkFeedAsReadContext marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
return err
}
// RefreshAllFeeds refreshes all feeds.
func (c *Client) RefreshAllFeeds() error {
_, err := c.request.Put("/v1/feeds/refresh", nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshAllFeedsContext(ctx)
}
// RefreshAllFeedsContext refreshes all feeds.
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
return err
}
// RefreshFeed refreshes a feed.
func (c *Client) RefreshFeed(feedID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshFeedContext(ctx, feedID)
}
// RefreshFeedContext refreshes a feed.
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
return err
}
// DeleteFeed removes a feed.
func (c *Client) DeleteFeed(feedID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteFeedContext(ctx, feedID)
}
// DeleteFeedContext removes a feed.
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
}
// FeedIcon gets a feed icon.
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/icon", feedID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedIconContext(ctx, feedID)
}
// FeedIconContext gets a feed icon.
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
if err != nil {
return nil, err
}
@@ -437,7 +819,14 @@ func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
// FeedEntry gets a single feed entry.
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntryContext(ctx, feedID, entryID)
}
// FeedEntryContext gets a single feed entry.
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
if err != nil {
return nil, err
}
@@ -453,7 +842,14 @@ func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
// CategoryEntry gets a single category entry.
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntryContext(ctx, categoryID, entryID)
}
// CategoryEntryContext gets a single category entry.
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
if err != nil {
return nil, err
}
@@ -469,7 +865,14 @@ func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
// Entry gets a single entry.
func (c *Client) Entry(entryID int64) (*Entry, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryContext(ctx, entryID)
}
// EntryContext gets a single entry.
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
if err != nil {
return nil, err
}
@@ -485,9 +888,16 @@ func (c *Client) Entry(entryID int64) (*Entry, error) {
// Entries fetch entries.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntriesContext(ctx, filter)
}
// EntriesContext fetches entries.
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString("/v1/entries", filter)
body, err := c.request.Get(path)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
@@ -503,9 +913,16 @@ func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
// FeedEntries fetch feed entries.
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntriesContext(ctx, feedID, filter)
}
// FeedEntriesContext fetches feed entries.
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
body, err := c.request.Get(path)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
@@ -521,9 +938,16 @@ func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, err
// CategoryEntries fetch entries of a category.
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntriesContext(ctx, categoryID, filter)
}
// CategoryEntriesContext fetches category entries.
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
body, err := c.request.Get(path)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
@@ -539,18 +963,32 @@ func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResult
// UpdateEntries updates the status of a list of entries.
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesContext(ctx, entryIDs, status)
}
// UpdateEntriesContext updates the status of a list of entries.
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntryContext(ctx, entryID, entryChanges)
}
// UpdateEntryContext updates an entry.
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
if err != nil {
return nil, err
}
@@ -564,21 +1002,42 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
return entry, nil
}
// ToggleBookmark toggles entry bookmark value.
func (c *Client) ToggleBookmark(entryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
// ToggleStarred toggles entry starred value.
func (c *Client) ToggleStarred(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ToggleStarredContext(ctx, entryID)
}
// ToggleStarredContext toggles entry starred value.
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
return err
}
// SaveEntry sends an entry to a third-party service.
func (c *Client) SaveEntry(entryID int64) error {
_, err := c.request.Post(fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.SaveEntryContext(ctx, entryID)
}
// SaveEntryContext sends an entry to a third-party service.
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
return err
}
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchEntryOriginalContentContext(ctx, entryID)
}
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
if err != nil {
return "", err
}
@@ -597,7 +1056,14 @@ func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
// FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) {
body, err := c.request.Get("/v1/feeds/counters")
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchCountersContext(ctx)
}
// FetchCountersContext fetches feed counters.
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
body, err := c.request.Get(ctx, "/v1/feeds/counters")
if err != nil {
return nil, err
}
@@ -613,13 +1079,27 @@ func (c *Client) FetchCounters() (*FeedCounters, error) {
// FlushHistory changes all entries with the status "read" to "removed".
func (c *Client) FlushHistory() error {
_, err := c.request.Put("/v1/flush-history", nil)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext changes all entries with the status "read" to "removed".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
}
// Icon fetches a feed icon.
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/icons/%d", iconID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IconContext(ctx, iconID)
}
// IconContext fetches a feed icon.
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
if err != nil {
return nil, err
}
@@ -635,7 +1115,14 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
// Enclosure fetches a specific enclosure.
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EnclosureContext(ctx, enclosureID)
}
// EnclosureContext fetches a specific enclosure.
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
if err != nil {
return nil, err
}
@@ -651,7 +1138,14 @@ func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
// UpdateEnclosure updates an enclosure.
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
}
// UpdateEnclosureContext updates an enclosure.
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
return err
}
File diff suppressed because it is too large Load Diff
+108 -59
View File
@@ -17,35 +17,37 @@ const (
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
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"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
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"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
@@ -63,33 +65,35 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
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"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
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"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
@@ -97,9 +101,12 @@ type Users []User
// Category represents a feed category.
type Category struct {
ID int64 `json:"id,omitempty"`
Title string `json:"title,omitempty"`
UserID int64 `json:"user_id,omitempty"`
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id,omitempty"`
HideGlobally bool `json:"hide_globally,omitempty"`
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c Category) String() string {
@@ -109,6 +116,18 @@ func (c Category) String() string {
// Categories represents a list of categories.
type Categories []*Category
// CategoryCreationRequest represents the request to create a category.
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
// CategoryModificationRequest represents the request to update a category.
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
// Subscription represents a feed subscription.
type Subscription struct {
Title string `json:"title"`
@@ -141,8 +160,11 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
@@ -151,6 +173,7 @@ type Feed struct {
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedCreationRequest represents the request to create a feed.
@@ -168,10 +191,14 @@ type FeedCreationRequest struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedModificationRequest represents the request to update a feed.
@@ -181,8 +208,11 @@ type FeedModificationRequest struct {
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
@@ -195,6 +225,7 @@ type FeedModificationRequest struct {
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// FeedIcon represents the feed icon.
@@ -307,6 +338,24 @@ type VersionResponse struct {
OS string `json:"os"`
}
// APIKey represents an application API key.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API keys.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create an API key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
func SetOptionalField[T any](value T) *T {
return &value
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import "net/http"
type Option func(*request)
// WithAPIKey sets the API key for the client.
func WithAPIKey(apiKey string) Option {
return func(r *request) {
r.apiKey = apiKey
}
}
// WithCredentials sets the username and password for the client.
func WithCredentials(username, password string) Option {
return func(r *request) {
r.username = username
r.password = password
}
}
// WithHTTPClient sets the HTTP client for the client.
func WithHTTPClient(client *http.Client) Option {
return func(r *request) {
r.client = client
}
}
+26 -24
View File
@@ -5,6 +5,7 @@ package client // import "miniflux.app/v2/client"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -17,7 +18,7 @@ import (
const (
userAgent = "Miniflux Client Library"
defaultTimeout = 80
defaultTimeout = 80 * time.Second
)
// List of exposed errors.
@@ -39,30 +40,36 @@ type request struct {
username string
password string
apiKey string
client *http.Client
}
func (r *request) Get(path string) (io.ReadCloser, error) {
return r.execute(http.MethodGet, path, nil)
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodGet, path, nil)
}
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
return r.execute(http.MethodPost, path, data)
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, data)
}
func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error) {
return r.execute(http.MethodPost, path, f)
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, f)
}
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
return r.execute(http.MethodPut, path, data)
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPut, path, data)
}
func (r *request) Delete(path string) error {
_, err := r.execute(http.MethodDelete, path, nil)
func (r *request) Delete(ctx context.Context, path string) error {
_, err := r.execute(ctx, http.MethodDelete, path, nil)
return err
}
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
func (r *request) execute(
ctx context.Context,
method string,
path string,
data any,
) (io.ReadCloser, error) {
if r.endpoint == "" {
return nil, ErrEmptyEndpoint
}
@@ -75,12 +82,13 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
return nil, err
}
request := &http.Request{
URL: u,
Method: method,
Header: r.buildHeaders(),
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
if err != nil {
return nil, err
}
request.Header = r.buildHeaders()
if r.username != "" && r.password != "" {
request.SetBasicAuth(r.username, r.password)
}
@@ -94,7 +102,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
}
}
client := r.buildClient()
client := r.client
response, err := client.Do(request)
if err != nil {
return nil, err
@@ -143,12 +151,6 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
return response.Body, nil
}
func (r *request) buildClient() http.Client {
return http.Client{
Timeout: defaultTimeout * time.Second,
}
}
func (r *request) buildHeaders() http.Header {
headers := make(http.Header)
headers.Add("User-Agent", userAgent)
@@ -160,7 +162,7 @@ func (r *request) buildHeaders() http.Header {
return headers
}
func (r *request) toJSON(v interface{}) []byte {
func (r *request) toJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
log.Println("Unable to convert interface to JSON:", err)
+2 -2
View File
@@ -19,14 +19,14 @@ services:
# healthcheck:
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+2 -2
View File
@@ -25,13 +25,13 @@ services:
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+2 -2
View File
@@ -37,13 +37,13 @@ services:
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
+28 -28
View File
@@ -1,50 +1,50 @@
module miniflux.app/v2
// +heroku goVersion go1.23
// +heroku goVersion go1.24
require (
github.com/PuerkitoBio/goquery v1.10.2
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.13.0
github.com/go-webauthn/webauthn v0.12.2
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-webauthn/webauthn v0.15.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/prometheus/client_golang v1.21.1
github.com/tdewolff/minify/v2 v2.22.4
golang.org/x/crypto v0.36.0
golang.org/x/image v0.25.0
golang.org/x/net v0.38.0
golang.org/x/oauth2 v0.28.0
golang.org/x/term v0.30.0
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.8
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/net v0.48.0
golang.org/x/oauth2 v0.34.0
golang.org/x/term v0.38.0
)
require (
github.com/go-webauthn/x v0.1.19 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.21 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.23.0
go 1.24.0
toolchain go1.24.1
+70 -55
View File
@@ -1,63 +1,69 @@
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/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.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-webauthn/webauthn v0.12.2 h1:yLaNPgBUEXDQtWnOjhsGhMMCEWbXwjg/aNkC8riJQI8=
github.com/go-webauthn/webauthn v0.12.2/go.mod h1:Q8SZPPj4sZ469fNTcQXxRpzJOdb30jQrn/36FX8jilA=
github.com/go-webauthn/x v0.1.19 h1:IUfdHiBRoTdujpBA/14qbrMXQ3LGzYe/PRGWdZcmudg=
github.com/go-webauthn/x v0.1.19/go.mod h1:C5arLuTQ3pVHKPw89v7CDGnqAZSZJj+4Jnr40dsn7tk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.22.4 h1:0/8K2fheOuYr5B4e5oCE1hGBVX6DQHLP0EGzdsDlYeg=
github.com/tdewolff/minify/v2 v2.22.4/go.mod h1:K/R8TT7aivpcU8QCNUU1UdR6etfnFPr7L11TO/X7shk=
github.com/tdewolff/parse/v2 v2.7.21 h1:OCuPFtGr4mXdnfKikQlUb0n654ROJANhBqCk+wioJ/A=
github.com/tdewolff/parse/v2 v2.7.21/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA=
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_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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -65,16 +71,22 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
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=
@@ -89,10 +101,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -111,8 +123,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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -122,8 +134,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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
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=
@@ -133,8 +145,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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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=
@@ -142,7 +154,10 @@ 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.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -1
View File
@@ -62,12 +62,14 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
@@ -76,6 +78,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
}
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
+393 -9
View File
@@ -8,12 +8,13 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"math/rand/v2"
"os"
"strings"
"testing"
miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
@@ -729,6 +730,116 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
}
}
func TestAPIKeysEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
apiKeys, err := regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
}
// Create an API key for the user.
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
if err != nil {
t.Fatal(err)
}
if apiKey.ID == 0 {
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
}
if apiKey.UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
}
if apiKey.Token == "" {
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
}
if apiKey.Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
}
// Create a duplicate API key with the same description.
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
}
// Fetch the API keys again.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 1 {
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
}
if apiKeys[0].ID != apiKey.ID {
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
}
if apiKeys[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
}
if apiKeys[0].Token != apiKey.Token {
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
}
if apiKeys[0].Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
}
// Create a new client using the API key.
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
// Fetch the user using the API key client.
user, err := apiKeyClient.Me()
if err != nil {
t.Fatal(err)
}
// Verify the user matches the regular test user.
if user.ID != regularTestUser.ID {
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
}
// Delete the API key.
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
t.Fatal(err)
}
// Verify the API key is deleted.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
}
// Try to delete the API key again, it should return an error.
err = regularUserClient.DeleteAPIKey(apiKey.ID)
if err == nil {
t.Fatal(`Deleting a non-existent API key should raise an error`)
}
if !errors.Is(err, miniflux.ErrNotFound) {
t.Fatalf(`Expected "not found" error, got %v`, err)
}
// Try to create an API key with an empty description.
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
t.Fatal(`Creating an API key with an empty description should raise an error`)
}
}
func TestMarkUserAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -824,6 +935,10 @@ func TestCreateCategoryEndpoint(t *testing.T) {
if category.Title != categoryName {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, category.Title, categoryName)
}
if category.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
}
func TestCreateCategoryWithEmptyTitle(t *testing.T) {
@@ -865,7 +980,49 @@ func TestCannotCreateDuplicatedCategory(t *testing.T) {
}
}
func TestUpdateCatgoryEndpoint(t *testing.T) {
func TestCreateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
HideGlobally: true,
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
for _, category := range categories {
if category.ID == newCategory.ID {
if category.Title != newCategory.Title {
t.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)
}
if category.HideGlobally != true {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
break
}
}
}
func TestUpdateCategoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
@@ -903,6 +1060,91 @@ func TestUpdateCatgoryEndpoint(t *testing.T) {
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateCategoryWithOptions(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: miniflux.SetOptionalField("new title"),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(true),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if !updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: miniflux.SetOptionalField(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateInexistingCategory(t *testing.T) {
@@ -1021,6 +1263,14 @@ func TestGetCategoriesEndpoint(t *testing.T) {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
}
if categories[0].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
}
if categories[0].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
}
if categories[1].ID != category.ID {
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
}
@@ -1032,6 +1282,40 @@ func TestGetCategoriesEndpoint(t *testing.T) {
if categories[1].Title != "My category" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
}
if categories[1].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
}
if categories[1].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
}
categories, err = regularUserClient.CategoriesWithCounters()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[1].FeedCount == nil {
t.Fatalf(`Expected FeedCount to be not nil`)
}
if categories[1].TotalUnread == nil {
t.Fatalf(`Expected TotalUnread to be not nil`)
}
expectedCounterValue := 0
if *categories[1].FeedCount != expectedCounterValue {
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
}
if *categories[1].TotalUnread != expectedCounterValue {
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
}
}
func TestMarkCategoryAsReadEndpoint(t *testing.T) {
@@ -2098,7 +2382,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
feedIDEntry, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
@@ -2130,6 +2413,64 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
}
func TestCannotGetRemovedEntries(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if feedEntries.Total == 0 {
t.Fatalf(`Expected at least one entry, got none`)
}
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if updatedFeedEntries.Total != feedEntries.Total-1 {
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2408,7 +2749,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
}
func TestToggleBookmarkEndpoint(t *testing.T) {
func TestToggleStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
@@ -2436,7 +2777,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
t.Fatal(err)
}
@@ -2446,7 +2787,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
}
if !entry.Starred {
t.Fatalf(`The entry should be bookmarked`)
t.Fatalf(`The entry should be starred`)
}
}
@@ -2591,13 +2932,56 @@ func TestFlushHistoryEndpoint(t *testing.T) {
if readEntries.Total != 0 {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)
// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)
payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}
// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if removedEntries.Total != 2 {
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}
// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, apiKey)
}
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, apiKeys)
}
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
json.NotFound(w, r)
return
}
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
+11 -10
View File
@@ -19,18 +19,18 @@ import (
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
var categoryCreationRequest model.CategoryCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryRequest)
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
if err != nil {
json.ServerError(w, r, err)
return
@@ -54,20 +54,20 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
return
}
var categoryRequest model.CategoryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
var categoryModificationRequest model.CategoryModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if err != nil {
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
@@ -143,6 +143,7 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
+2 -1
View File
@@ -7,6 +7,7 @@ import (
json_parser "encoding/json"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
@@ -33,7 +34,7 @@ func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
return
}
enclosure.ProxifyEnclosureURL(h.router)
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, enclosure)
}
+128 -5
View File
@@ -10,6 +10,8 @@ import (
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
@@ -17,6 +19,7 @@ import (
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
@@ -34,8 +37,7 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, entry)
}
@@ -47,6 +49,7 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -58,6 +61,7 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -66,6 +70,7 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -136,6 +141,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
@@ -186,9 +192,9 @@ func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
json.NoContent(w, r)
}
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
json.ServerError(w, r, err)
return
}
@@ -270,6 +276,11 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
@@ -283,6 +294,110 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
json.Created(w, r, entry)
}
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("feed does not exist"))
return
}
var req EntryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&req); err != nil {
json.BadRequest(w, r, err)
return
}
if req.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}
if req.Status == "" {
req.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(req.Status); err != nil {
json.BadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = req.URL
entry.CommentsURL = req.CommentsURL
entry.Author = req.Author
entry.Tags = req.Tags
if req.PublishedAt > 0 {
entry.Date = time.Unix(req.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if req.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = req.Title
}
hashInput := req.ExternalID
if hashInput == "" {
hashInput = req.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, r)
return
}
if req.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, req.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if err != nil {
json.ServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, req.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = req.Status
if req.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
json.Created(w, r, map[string]int64{"id": entry.ID})
} else {
json.OK(w, r, map[string]int64{"id": entry.ID})
}
}
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
@@ -331,7 +446,15 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
json.OK(w, r, map[string]string{"content": entry.Content})
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)
return
}
}
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
}
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
+2 -7
View File
@@ -76,6 +76,7 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
@@ -140,13 +141,7 @@ func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
feed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, r)
return
}
if feed == nil {
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
+8
View File
@@ -43,6 +43,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
next.ServeHTTP(w, r)
return
@@ -59,6 +60,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -69,6 +71,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", user.Username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
@@ -100,6 +103,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -110,6 +114,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -121,6 +126,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -138,6 +144,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -148,6 +155,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
+14
View File
@@ -18,6 +18,20 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
// EntryImportRequest represents a manually imported entry for a feed.
type EntryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/validator"
@@ -29,24 +30,29 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
}
var rssbridgeURL string
var rssbridgeToken string
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
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.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
rssbridgeToken,
)
if localizedError != nil {
-14
View File
@@ -7,8 +7,6 @@ import (
json_parser "encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
@@ -84,18 +82,6 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
}
}
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
if userModificationRequest.BlockFilterEntryRules != nil {
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
// Clean carriage returns for Windows environments
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
}
if userModificationRequest.KeepFilterEntryRules != nil {
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
// Clean carriage returns for Windows environments
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
+3 -2
View File
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
@@ -16,7 +17,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")
@@ -30,6 +31,6 @@ func askCredentials() (string, string) {
defer term.Restore(fd, state)
bytePassword, _ := term.ReadPassword(fd)
fmt.Printf("\n")
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}
+18 -4
View File
@@ -14,15 +14,15 @@ import (
)
func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive read entries", slog.Any("error", err))
} else {
slog.Info("Archiving read entries completed",
@@ -35,7 +35,7 @@ func runCleanupTasks(store *storage.Storage) {
}
startTime = time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive unread entries", slog.Any("error", err))
} else {
slog.Info("Archiving unread entries completed",
@@ -46,4 +46,18 @@ func runCleanupTasks(store *storage.Storage) {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
}
}
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
}
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
} else {
slog.Info("Clearing content from removed entries completed",
slog.Int64("removed_entries_content_cleared", contentAffected))
}
}
+64 -37
View File
@@ -13,46 +13,49 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
)
const (
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
// Parse parses command line arguments.
func Parse() {
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagResetFeedNextCheckAt bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
@@ -64,6 +67,7 @@ func Parse() {
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
flag.BoolVar(&flagResetFeedNextCheckAt, "reset-feed-next-check-at", false, flagResetNextCheckAtHelp)
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
@@ -74,7 +78,7 @@ func Parse() {
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
flag.Parse()
cfg := config.NewParser()
cfg := config.NewConfigParser()
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
@@ -105,6 +109,12 @@ func Parse() {
}
}
if config.Opts.AuthProxyHeader() != "" {
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
}
}
if flagConfigDump {
fmt.Print(config.Opts)
return
@@ -152,16 +162,16 @@ func Parse() {
slog.Info("The default value for DATABASE_URL is used")
}
if err := static.CalculateBinaryFileChecksums(); err != nil {
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
}
if err := static.GenerateJavascriptBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %v", err))
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}
db, err := database.NewConnectionPool(
@@ -189,7 +199,16 @@ func Parse() {
}
if flagResetFeedErrors {
store.ResetFeedErrors()
if err := store.ResetFeedErrors(); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedNextCheckAt {
if err := store.ResetNextCheckAt(); err != nil {
printErrorAndExit(err)
}
return
}
@@ -228,6 +247,14 @@ func Parse() {
createAdminUserFromEnvironmentVariables(store)
}
if config.Opts.HasHTTPClientProxiesConfigured() {
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))
}
}
if flagRefreshFeeds {
refreshFeeds(store)
return
@@ -242,6 +269,6 @@ func Parse() {
}
func printErrorAndExit(err error) {
fmt.Fprintf(os.Stderr, "%v\n", err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+15 -5
View File
@@ -13,7 +13,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
httpd "miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd"
@@ -33,9 +33,9 @@ func startDaemon(store *storage.Storage) {
runScheduler(store, pool)
}
var httpServer *http.Server
var httpServers []*http.Server
if config.Opts.HasHTTPService() {
httpServer = httpd.StartWebServer(store, pool)
httpServers = server.StartWebServer(store, pool)
}
if config.Opts.HasMetricsCollector() {
@@ -78,8 +78,18 @@ func startDaemon(store *storage.Storage) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if httpServer != nil {
httpServer.Shutdown(ctx)
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))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Process gracefully stopped")
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
}
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
+3 -6
View File
@@ -25,6 +25,7 @@ func refreshFeeds(store *storage.Storage) {
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
@@ -32,13 +33,9 @@ func refreshFeeds(store *storage.Storage) {
return
}
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", nbJobs),
slog.Int("batch_size", config.Opts.BatchSize()),
)
var jobQueue = make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
+2 -1
View File
@@ -4,6 +4,7 @@
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"fmt"
"miniflux.app/v2/internal/model"
@@ -19,7 +20,7 @@ func resetPassword(store *storage.Storage) {
}
if user == nil {
printErrorAndExit(fmt.Errorf("user not found"))
printErrorAndExit(errors.New("user not found"))
}
userModificationRequest := &model.UserModificationRequest{
+8 -8
View File
@@ -21,36 +21,36 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
config.Opts.PollingFrequency(),
config.Opts.BatchSize(),
config.Opts.PollingParsingErrorLimit(),
config.Opts.PollingLimitPerHost(),
)
go cleanupScheduler(
store,
config.Opts.CleanupFrequencyHours(),
config.Opts.CleanupFrequency(),
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
for range time.Tick(time.Duration(frequency) * time.Minute) {
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)
if jobs, err := batchBuilder.FetchJobs(); err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", len(jobs)),
)
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
pool.Push(jobs)
}
}
}
func cleanupScheduler(store *storage.Storage, frequency int) {
for range time.Tick(time.Duration(frequency) * time.Hour) {
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
for range time.Tick(frequency) {
runCleanupTasks(store)
}
}
+5 -1
View File
@@ -3,5 +3,9 @@
package config // import "miniflux.app/v2/internal/config"
import "miniflux.app/v2/internal/version"
// Opts holds parsed configuration options.
var Opts *Options
var Opts *configOptions
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
File diff suppressed because it is too large Load Diff
+874 -620
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+214 -296
View File
@@ -10,328 +10,212 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Parser handles configuration parsing.
type Parser struct {
opts *Options
type configParser struct {
options *configOptions
}
// NewParser returns a new Parser.
func NewParser() *Parser {
return &Parser{
opts: NewOptions(),
func NewConfigParser() *configParser {
return &configParser{
options: NewConfigOptions(),
}
}
// ParseEnvironmentVariables loads configuration values from environment variables.
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
err := p.parseLines(os.Environ())
if err != nil {
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
if err := cp.parseLines(os.Environ()); err != nil {
return nil, err
}
return p.opts, nil
return cp.options, nil
}
// ParseFile loads configuration values from a local file.
func (p *Parser) ParseFile(filename string) (*Options, error) {
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
err = p.parseLines(p.parseFileContent(fp))
if err != nil {
if err := cp.parseLines(parseFileContent(fp)); err != nil {
return nil, err
}
return p.opts, nil
return cp.options, nil
}
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].parsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
func (p *Parser) parseLines(lines []string) (err error) {
var port string
for _, line := range lines {
fields := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
switch key {
case "LOG_FILE":
p.opts.logFile = parseString(value, defaultLogFile)
case "LOG_DATE_TIME":
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
case "LOG_LEVEL":
parsedValue := parseString(value, defaultLogLevel)
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
p.opts.logLevel = parsedValue
}
case "LOG_FORMAT":
parsedValue := parseString(value, defaultLogFormat)
if parsedValue == "json" || parsedValue == "text" {
p.opts.logFormat = parsedValue
}
case "DEBUG":
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
}
case "SERVER_TIMING_HEADER":
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
return err
}
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseString(value, defaultListenAddr)
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_URL_FILE":
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
case "DATABASE_MAX_CONNS":
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
case "DATABASE_MIN_CONNS":
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "DATABASE_CONNECTION_LIFETIME":
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
case "FILTER_ENTRY_MAX_AGE_DAYS":
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS":
p.opts.hsts = !parseBool(value, defaultHSTS)
case "HTTPS":
p.opts.HTTPS = parseBool(value, defaultHTTPS)
case "DISABLE_SCHEDULER_SERVICE":
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
case "DISABLE_HTTP_SERVICE":
p.opts.httpService = !parseBool(value, defaultHTTPService)
case "CERT_FILE":
p.opts.certFile = parseString(value, defaultCertFile)
case "KEY_FILE":
p.opts.certKeyFile = parseString(value, defaultKeyFile)
case "CERT_DOMAIN":
p.opts.certDomain = parseString(value, defaultCertDomain)
case "CLEANUP_FREQUENCY_HOURS":
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
case "CLEANUP_ARCHIVE_READ_DAYS":
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
case "CLEANUP_ARCHIVE_BATCH_SIZE":
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
case "CLEANUP_REMOVE_SESSIONS_DAYS":
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
case "WORKER_POOL_SIZE":
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "FORCE_REFRESH_INTERVAL":
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "POLLING_SCHEDULER":
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "PROXY_IMAGES":
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_HTTP_CLIENT_TIMEOUT":
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "PROXY_OPTION":
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_MEDIA_TYPES":
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "PROXY_IMAGE_URL":
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_URL":
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_PRIVATE_KEY":
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
randomKey := make([]byte, 16)
if _, err := rand.Read(randomKey); err != nil {
return fmt.Errorf("config: unable to generate random key: %w", err)
}
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
if _, err := rand.Read(randomKey); err != nil {
return fmt.Errorf("config: unable to generate random key: %w", err)
}
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
p.opts.adminUsername = parseString(value, defaultAdminUsername)
case "ADMIN_USERNAME_FILE":
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
case "ADMIN_PASSWORD":
p.opts.adminPassword = parseString(value, defaultAdminPassword)
case "ADMIN_PASSWORD_FILE":
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
case "POCKET_CONSUMER_KEY":
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
case "POCKET_CONSUMER_KEY_FILE":
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_ID_FILE":
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_SECRET":
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
case "OAUTH2_CLIENT_SECRET_FILE":
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
case "OAUTH2_REDIRECT_URL":
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
case "OAUTH2_OIDC_PROVIDER_NAME":
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
case "OAUTH2_PROVIDER":
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
case "DISABLE_LOCAL_AUTH":
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
case "HTTP_CLIENT_TIMEOUT":
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
case "HTTP_CLIENT_MAX_BODY_SIZE":
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
case "HTTP_CLIENT_PROXY":
p.opts.httpClientProxy = parseString(value, defaultHTTPClientProxy)
case "HTTP_CLIENT_USER_AGENT":
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
case "HTTP_SERVER_TIMEOUT":
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
case "AUTH_PROXY_HEADER":
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
case "AUTH_PROXY_USER_CREATION":
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
case "MAINTENANCE_MODE":
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
case "MAINTENANCE_MESSAGE":
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
case "METRICS_COLLECTOR":
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
case "METRICS_REFRESH_INTERVAL":
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
case "METRICS_ALLOWED_NETWORKS":
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
case "METRICS_USERNAME":
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
case "METRICS_USERNAME_FILE":
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
case "METRICS_PASSWORD":
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_BILIBILI_WATCH_TIME":
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
case "FETCH_NEBULA_WATCH_TIME":
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
case "YOUTUBE_API_KEY":
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
case "YOUTUBE_EMBED_URL_OVERRIDE":
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
case "WATCHDOG":
p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
}
}
if port != "" {
p.opts.listenAddr = ":" + port
}
return nil
}
func parseBaseURL(value string) (string, string, string, error) {
if value == "" {
return defaultBaseURL, defaultRootURL, "", nil
}
if value[len(value)-1:] == "/" {
value = value[:len(value)-1]
}
parsedURL, err := url.Parse(value)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
return fmt.Errorf("invalid BASE_URL: %v", err)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
return errors.New("BASE_URL scheme must be http or https")
}
basePath := parsedURL.Path
cp.options.options["BASE_URL"].parsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
return value, parsedURL.String(), basePath, nil
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
}
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
}
return nil
}
func parseBool(value string, fallback bool) bool {
func (cp *configParser) parseLines(lines []string) error {
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if err := cp.parseLine(key, value); err != nil {
return err
}
}
if err := cp.postParsing(); err != nil {
return err
}
return nil
}
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
// Validate the option if a validator is provided
if field.validator != nil {
if err := field.validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.valueType {
case stringType:
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
field.rawValue = value
case stringListType:
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
field.rawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.parsedBoolValue = parsedValue
field.rawValue = value
case intType:
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
field.rawValue = value
case int64Type:
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
field.rawValue = value
case secondType:
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
field.rawValue = value
case minuteType:
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
field.rawValue = value
case hourType:
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
field.rawValue = value
case dayType:
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
field.rawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.parsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.parsedURLValue = parsedURL
field.rawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.targetKey != "" {
if targetField, ok := cp.options.options[field.targetKey]; ok {
targetField.parsedStringValue = secretValue
targetField.rawValue = secretValue
}
}
field.rawValue = value
case bytesType:
if value != "" {
field.parsedBytesValue = []byte(value)
field.rawValue = value
}
}
return nil
}
func parseStringValue(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func parseBoolValue(value string, fallback bool) (bool, error) {
if value == "" {
return fallback, nil
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true
return true, nil
}
if value == "0" || value == "no" || value == "false" || value == "off" {
return false, nil
}
return false
return false, fmt.Errorf("invalid boolean value: %q", value)
}
func parseInt(value string, fallback int) int {
func parseIntValue(value string, fallback int) int {
if value == "" {
return fallback
}
@@ -344,52 +228,86 @@ func parseInt(value string, fallback int) int {
return v
}
func parseString(value string, fallback string) string {
func ParsedInt64Value(value string, fallback int64) int64 {
if value == "" {
return fallback
}
return value
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fallback
}
return v
}
func parseStringList(value string, fallback []string) []string {
func parseStringListValue(value string, fallback []string) []string {
if value == "" {
return fallback
}
var strList []string
strMap := make(map[string]bool)
present := make(map[string]bool)
items := strings.Split(value, ",")
for _, item := range items {
itemValue := strings.TrimSpace(item)
if _, found := strMap[itemValue]; !found {
strMap[itemValue] = true
strList = append(strList, itemValue)
for item := range strings.SplitSeq(value, ",") {
if itemValue := strings.TrimSpace(item); itemValue != "" {
if !present[itemValue] {
present[itemValue] = true
strList = append(strList, itemValue)
}
}
}
return strList
}
func parseBytes(value string, fallback []byte) []byte {
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
if value == "" {
return fallback
}
return []byte(value)
}
func readSecretFile(filename, fallback string) string {
data, err := os.ReadFile(filename)
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
value := string(bytes.TrimSpace(data))
return time.Duration(v) * unit
}
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
if value == "" {
return fallback
return fallback, nil
}
return value
parsedURL, err := url.Parse(value)
if err != nil {
return fallback, err
}
return parsedURL, nil
}
func readSecretFileValue(filename string) (string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return "", err
}
value := string(bytes.TrimSpace(data))
if value == "" {
return "", errors.New("secret file is empty")
}
return value, nil
}
func parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
+413 -38
View File
@@ -4,57 +4,432 @@
package config // import "miniflux.app/v2/internal/config"
import (
"net/url"
"os"
"reflect"
"testing"
"time"
)
func TestParseBoolValue(t *testing.T) {
scenarios := map[string]bool{
"": true,
"1": true,
"Yes": true,
"yes": true,
"True": true,
"true": true,
"on": true,
"false": false,
"off": false,
"invalid": false,
func TestParseStringValue(t *testing.T) {
// Test with non-empty value
result := parseStringValue("test", "fallback")
if result != "test" {
t.Errorf("Expected 'test', got '%s'", result)
}
for input, expected := range scenarios {
result := parseBool(input, true)
if result != expected {
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
// Test with empty value
result = parseStringValue("", "fallback")
if result != "fallback" {
t.Errorf("Expected 'fallback', got '%s'", result)
}
// Test with empty value and empty fallback
result = parseStringValue("", "")
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestParseBoolValue(t *testing.T) {
// Test with empty value - should return fallback
result, err := parseBoolValue("", true)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != true {
t.Errorf("Expected true, got %v", result)
}
// Test true values
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
for _, value := range trueValues {
result, err := parseBoolValue(value, false)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != true {
t.Errorf("Expected true for '%s', got %v", value, result)
}
}
}
func TestParseStringValueWithUnsetVariable(t *testing.T) {
if parseString("", "defaultValue") != "defaultValue" {
t.Errorf(`Unset variables should returns the default value`)
// Test false values
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
for _, value := range falseValues {
result, err := parseBoolValue(value, true)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != false {
t.Errorf("Expected false for '%s', got %v", value, result)
}
}
}
func TestParseStringValue(t *testing.T) {
if parseString("test", "defaultValue") != "test" {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseIntValueWithUnsetVariable(t *testing.T) {
if parseInt("", 42) != 42 {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseIntValueWithInvalidInput(t *testing.T) {
if parseInt("invalid integer", 42) != 42 {
t.Errorf(`Invalid integer should returns the default value`)
// Test invalid value - should return error
_, err = parseBoolValue("invalid", false)
if err == nil {
t.Error("Expected error for invalid boolean value")
}
}
func TestParseIntValue(t *testing.T) {
if parseInt("2018", 42) != 2018 {
t.Errorf(`Defined variables should returns the specified value`)
// Test with empty value - should return fallback
result := parseIntValue("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid integer
result = parseIntValue("123", 42)
if result != 123 {
t.Errorf("Expected 123, got %d", result)
}
// Test with invalid integer - should return fallback
result = parseIntValue("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with zero
result = parseIntValue("0", 42)
if result != 0 {
t.Errorf("Expected 0, got %d", result)
}
}
func TestParsedInt64Value(t *testing.T) {
// Test with empty value - should return fallback
result := ParsedInt64Value("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid int64
result = ParsedInt64Value("9223372036854775807", 42)
if result != 9223372036854775807 {
t.Errorf("Expected 9223372036854775807, got %d", result)
}
// Test with invalid int64 - should return fallback
result = ParsedInt64Value("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
}
func TestParseStringListValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := []string{"a", "b"}
result := parseStringListValue("", fallback)
if !reflect.DeepEqual(result, fallback) {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with single value
result = parseStringListValue("item1", nil)
expected := []string{"item1"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with multiple values
result = parseStringListValue("item1,item2,item3", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with duplicates - should remove duplicates
result = parseStringListValue("item1,item2,item1", nil)
expected = []string{"item1", "item2"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with spaces
result = parseStringListValue(" item1 , item2 , item3 ", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestParseDurationValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := 5 * time.Second
result := parseDurationValue("", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with valid duration
result = parseDurationValue("30", time.Second, fallback)
expected := 30 * time.Second
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with minutes
result = parseDurationValue("5", time.Minute, fallback)
expected = 5 * time.Minute
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with invalid value - should return fallback
result = parseDurationValue("invalid", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
}
func TestParseURLValue(t *testing.T) {
// Test with empty value - should return fallback
fallbackURL, _ := url.Parse("https://fallback.com")
result, err := parseURLValue("", fallbackURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != fallbackURL {
t.Errorf("Expected %v, got %v", fallbackURL, result)
}
// Test with valid URL
result, err = parseURLValue("https://example.com", nil)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.String() != "https://example.com" {
t.Errorf("Expected https://example.com, got %s", result.String())
}
// Test with invalid URL - should return fallback and error
result, err = parseURLValue("://invalid", fallbackURL)
if err == nil {
t.Error("Expected error for invalid URL")
}
if result != fallbackURL {
t.Errorf("Expected fallback URL, got %v", result)
}
}
func TestConfigFileParsing(t *testing.T) {
fileContent := `
# This is a comment
LOG_FILE=miniflux.log
LOG_DATE_TIME=1
LOG_FORMAT=json
LISTEN_ADDR=:8080,:8443
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
configOptions, err := configParser.ParseFile(filename)
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
if configOptions.LogFormat() != "json" {
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
}
if configOptions.LogLevel() != "info" {
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
}
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
}
}
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
fileContent := `
LOG_FILE=miniflux.log
INVALID_LINE
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
_, err = configParser.ParseFile(filename)
if err != nil {
t.Fatal("Invalid lines should be ignored, but got error:", err)
}
}
func TestParseAdminPasswordFileOption(t *testing.T) {
tmpFile, err := os.CreateTemp("", "password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
password := "supersecret"
if _, err := tmpFile.WriteString(password); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.AdminPassword() != password {
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
}
}
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
_, err = configParser.ParseEnvironmentVariables()
if err == nil {
t.Fatal("Expected error due to empty password file, but got none")
}
}
func TestParseLogFileOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "miniflux.log")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "true")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "invalid")
configParser := NewConfigParser()
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
t.Fatal("Expected parsing error, got nil")
}
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
)
func validateChoices(rawValue string, choices []string) error {
if !slices.Contains(choices, rawValue) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
}
return nil
}
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
}
func validateGreaterThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue > min {
return nil
}
return fmt.Errorf("value must be at least %d", min)
}
func validateGreaterOrEqualThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue >= min {
return nil
}
return fmt.Errorf("value must be greater or equal than %d", min)
}
func validateRange(rawValue string, min, max int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue < min || intValue > max {
return fmt.Errorf("value must be between %d and %d", min, max)
}
return nil
}
+372
View File
@@ -0,0 +1,372 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"strings"
"testing"
)
func TestValidateChoices(t *testing.T) {
tests := []struct {
name string
rawValue string
choices []string
expectError bool
}{
{
name: "valid choice",
rawValue: "option1",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from middle",
rawValue: "option2",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from end",
rawValue: "option3",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "invalid choice",
rawValue: "invalid",
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "empty value with non-empty choices",
rawValue: "",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "case sensitive - different case",
rawValue: "OPTION1",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "single choice valid",
rawValue: "only",
choices: []string{"only"},
expectError: false,
},
{
name: "empty choices list",
rawValue: "anything",
choices: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChoices(tt.rawValue, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateListChoices(t *testing.T) {
tests := []struct {
name string
inputValues []string
choices []string
expectError bool
}{
{
name: "all valid choices",
inputValues: []string{"option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "single valid choice",
inputValues: []string{"option1"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "empty input list",
inputValues: []string{},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "all choices from available list",
inputValues: []string{"option1", "option2", "option3"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "duplicate valid choices",
inputValues: []string{"option1", "option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "one invalid choice",
inputValues: []string{"option1", "invalid"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "all invalid choices",
inputValues: []string{"invalid1", "invalid2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "case sensitive - different case",
inputValues: []string{"OPTION1"},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty string in input",
inputValues: []string{""},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty choices list with non-empty input",
inputValues: []string{"anything"},
choices: []string{},
expectError: true,
},
{
name: "mixed valid and invalid choices",
inputValues: []string{"option1", "invalid", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateListChoices(tt.inputValues, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateGreaterThan(t *testing.T) {
if err := validateGreaterThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterThan("5", 5); err == nil {
t.Errorf("expected error, got none")
}
if err := validateGreaterThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateGreaterOrEqualThan(t *testing.T) {
if err := validateGreaterOrEqualThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterOrEqualThan("5", 5); err != nil {
t.Errorf("expected no error for equal value, got: %v", err)
}
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateRange(t *testing.T) {
tests := []struct {
name string
rawValue string
min int
max int
expectError bool
errorMsg string
}{
{
name: "valid integer within range",
rawValue: "5",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at minimum",
rawValue: "1",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at maximum",
rawValue: "10",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid zero in range",
rawValue: "0",
min: -5,
max: 5,
expectError: false,
},
{
name: "valid negative in range",
rawValue: "-3",
min: -5,
max: 5,
expectError: false,
},
{
name: "integer below minimum",
rawValue: "0",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer above maximum",
rawValue: "11",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far below minimum",
rawValue: "-100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far above maximum",
rawValue: "100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "non-integer string",
rawValue: "abc",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "empty string",
rawValue: "",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "float string",
rawValue: "5.5",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "string with spaces",
rawValue: " 5 ",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "single value range",
rawValue: "5",
min: 5,
max: 5,
expectError: false,
},
{
name: "single value range - below",
rawValue: "4",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
{
name: "single value range - above",
rawValue: "6",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRange(tt.rawValue, tt.min, tt.max)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
+10 -15
View File
@@ -8,38 +8,33 @@ import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"hash/fnv"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a SHA-256 checksum of the input.
// HashFromBytes returns a non-cryptographic checksum of the input.
func HashFromBytes(value []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(value))
h := fnv.New128a()
h.Write(value)
return hex.EncodeToString(h.Sum(nil))
}
// Hash returns a SHA-256 checksum of a string.
func Hash(value string) string {
return HashFromBytes([]byte(value))
// SHA256 returns a SHA-256 checksum of a string.
func SHA256(value string) string {
h := sha256.Sum256([]byte(value))
return hex.EncodeToString(h[:])
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
panic(err)
}
rand.Read(b)
return b
}
// GenerateRandomString returns a random string.
func GenerateRandomString(size int) string {
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
+2 -4
View File
@@ -14,11 +14,9 @@ func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
driver := getDriverStr()
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
slog.String("driver", driver),
)
for version := currentVersion; version < schemaVersion; version++ {
@@ -29,12 +27,12 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx, driver); err != nil {
if err := migrations[version](tx); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,5 +1,3 @@
//go:build !sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
@@ -25,7 +23,3 @@ func NewConnectionPool(dsn string, minConnections, maxConnections int, connectio
return db, nil
}
func getDriverStr() string {
return "postgresql"
}
-26
View File
@@ -1,26 +0,0 @@
//go:build sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}
func getDriverStr() string {
return "sqlite3"
}
+11 -11
View File
@@ -143,9 +143,9 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
}
var result feedsResponse
result.Feeds = make([]feed, 0)
result.Feeds = make([]feed, 0, len(feeds))
for _, f := range feeds {
subscripion := feed{
subscription := feed{
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
@@ -155,10 +155,10 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
}
if f.Icon != nil {
subscripion.FaviconID = f.Icon.IconID
subscription.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscripion)
result.Feeds = append(result.Feeds, subscription)
}
result.FeedsGroups = h.buildFeedGroups(feeds)
@@ -307,7 +307,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
return
}
result.Items = make([]item, 0)
result.Items = make([]item, 0, len(entries))
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
@@ -358,7 +358,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
return
}
var itemIDs []string
itemIDs := make([]string, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
}
@@ -392,7 +392,7 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
return
}
var itemsIDs []string
itemsIDs := make([]string, 0, len(entryIDs))
for _, entryID := range entryIDs {
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
}
@@ -455,7 +455,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
@@ -474,7 +474,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
@@ -568,12 +568,12 @@ A feeds_group object has the following members:
feed_ids (string/comma-separated list of positive integers)
*/
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string)
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
}
result := make([]feedsGroups, 0)
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
for categoryID, feedIDs := range feedsGroupedByCategory {
result = append(result, feedsGroups{
GroupID: categoryID,
+225 -398
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/config"
@@ -20,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription"
@@ -34,178 +34,12 @@ type handler struct {
router *mux.Router
}
const (
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
StreamPrefix = "user/-/state/com.google/"
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
UserStreamPrefix = "user/%d/state/com.google/"
// LabelPrefix is the prefix for a label stream
LabelPrefix = "user/-/label/"
// UserLabelPrefix is the user specific prefix prefix for a label stream
UserLabelPrefix = "user/%d/label/"
// FeedPrefix is the prefix for a feed stream
FeedPrefix = "feed/"
// Read is the suffix for read stream
Read = "read"
// Starred is the suffix for starred stream
Starred = "starred"
// ReadingList is the suffix for reading list stream
ReadingList = "reading-list"
// KeptUnread is the suffix for kept unread stream
KeptUnread = "kept-unread"
// Broadcast is the suffix for broadcast stream
Broadcast = "broadcast"
// BroadcastFriends is the suffix for broadcast friends stream
BroadcastFriends = "broadcast-friends"
// Like is the suffix for like stream
Like = "like"
// EntryIDLong is the long entry id representation
EntryIDLong = "tag:google.com,2005:reader/item/%016x"
var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
)
const (
// ParamItemIDs - name of the parameter with the item ids
ParamItemIDs = "i"
// ParamStreamID - name of the parameter containing the stream to be included
ParamStreamID = "s"
// ParamStreamExcludes - name of the parameter containing streams to be excluded
ParamStreamExcludes = "xt"
// ParamStreamFilters - name of the parameter containing streams to be included
ParamStreamFilters = "it"
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
ParamStreamMaxItems = "n"
// ParamStreamOrder - name of the parameter containing the sort criteria
ParamStreamOrder = "r"
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
ParamStreamStartTime = "ot"
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
ParamStreamStopTime = "nt"
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
ParamTagsRemove = "r"
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
ParamTagsAdd = "a"
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
ParamSubscribeAction = "ac"
// ParamTitle - name of the parameter for the title of the subscription
ParamTitle = "t"
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
ParamQuickAdd = "quickadd"
// ParamDestination - name of the parameter for the new name of a tag
ParamDestination = "dest"
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
ParamContinuation = "c"
)
// StreamType represents the possible stream types
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
// RequestModifiers are the parsed request parameters.
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
// Serve handles Google Reader API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
@@ -213,9 +47,7 @@ func Serve(router *mux.Router, store *storage.Storage) {
middleware := newMiddleware(store)
sr := router.PathPrefix("/reader/api/0").Subrouter()
sr.Use(middleware.handleCORS)
sr.Use(middleware.apiKeyAuth)
sr.Methods(http.MethodOptions)
sr.HandleFunc("/token", handler.tokenHandler).Methods(http.MethodGet).Name("Token")
sr.HandleFunc("/edit-tag", handler.editTagHandler).Methods(http.MethodPost).Name("EditTag")
sr.HandleFunc("/rename-tag", handler.renameTagHandler).Methods(http.MethodPost).Name("Rename Tag")
@@ -227,102 +59,22 @@ func Serve(router *mux.Router, store *storage.Storage) {
sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
sr.HandleFunc("/mark-all-as-read", handler.markAllAsReadHandler).Methods(http.MethodPost).Name("MarkAllAsRead")
sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
}
func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
return result, nil
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix)
switch id {
case Read:
return Stream{ReadStream, ""}, nil
case Starred:
return Stream{StarredStream, ""}, nil
case ReadingList:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
tags := make(map[StreamType]bool)
for _, s := range addTags {
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case StarredStream:
@@ -337,17 +89,17 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case StarredStream:
if _, ok := tags[StarredStream]; ok {
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", Starred)
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", starredStreamSuffix)
}
tags[StarredStream] = false
case BroadcastStream, LikeStream:
@@ -360,28 +112,6 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
return tags, nil
}
func getItemIDs(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
var itemID int64
_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
if err != nil {
itemID, err = strconv.ParseInt(item, 16, 64)
if err != nil {
return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
}
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
@@ -394,8 +124,7 @@ func checkOutputFormat(r *http.Request) error {
output = request.QueryStringParam(r, "output", "")
}
if output != "json" {
err := fmt.Errorf("googlereader: only json output is supported")
return err
return errors.New("googlereader: only json output is supported")
}
return nil
}
@@ -468,7 +197,7 @@ func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
slog.String("username", username),
)
result := login{SID: token, LSID: token, Auth: token}
result := loginResponse{SID: token, LSID: token, Auth: token}
if output == "json" {
json.OK(w, r, result)
return
@@ -498,7 +227,7 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
token := request.GoolgeReaderToken(r)
token := request.GoogleReaderToken(r)
if token == "" {
slog.Warn("[GoogleReader] User does not have token",
slog.String("client_ip", clientIP),
@@ -513,7 +242,6 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", request.UserID(r)),
slog.String("token", token),
)
w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
@@ -537,18 +265,18 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
addTags, err := getStreams(r.PostForm[paramTagsAdd], userID)
if err != nil {
json.ServerError(w, r, err)
return
}
removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
removeTags, err := getStreams(r.PostForm[paramTagsRemove], userID)
if err != nil {
json.ServerError(w, r, err)
return
}
if len(addTags) == 0 && len(removeTags) == 0 {
err = fmt.Errorf("googlreader: add or/and remove tags should be supplied")
err = errors.New("googlreader: add or/and remove tags should be supplied")
json.ServerError(w, r, err)
return
}
@@ -558,9 +286,9 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -625,7 +353,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
if len(unstarredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
err = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)
if err != nil {
json.ServerError(w, r, err)
return
@@ -633,7 +361,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
if len(starredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
err = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)
if err != nil {
json.ServerError(w, r, err)
return
@@ -655,7 +383,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
@@ -675,7 +403,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
return
}
feedURL := r.Form.Get(ParamQuickAdd)
feedURL := r.Form.Get(paramQuickAdd)
if !validator.IsValidURL(feedURL) {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", feedURL))
return
@@ -683,14 +411,16 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
var rssBridgeURL string
var rssBridgeToken string
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
rssBridgeURL = intg.RSSBridgeURL
rssBridgeToken = intg.RSSBridgeToken
}
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL)
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
@@ -722,7 +452,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, quickAddResponse{
NumResults: 1,
Query: newFeed.FeedURL,
StreamID: fmt.Sprintf(FeedPrefix+"%d", newFeed.ID),
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
StreamName: newFeed.Title,
})
}
@@ -735,17 +465,16 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
return store.FeedByID(userID, feedID)
}
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
func getOrCreateCategory(streamCategory Stream, store *storage.Storage, userID int64) (*model.Category, error) {
switch {
case category.ID == "":
case streamCategory.ID == "":
return store.FirstCategory(userID)
case store.CategoryTitleExists(userID, category.ID):
return store.CategoryByTitle(userID, category.ID)
case store.CategoryTitleExists(userID, streamCategory.ID):
return store.CategoryByTitle(userID, streamCategory.ID)
default:
catRequest := model.CategoryRequest{
Title: category.ID,
}
return store.CreateCategory(userID, &catRequest)
return store.CreateCategory(userID, &model.CategoryCreationRequest{
Title: streamCategory.ID,
})
}
}
@@ -796,14 +525,25 @@ func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
return nil
}
func rename(stream Stream, title string, store *storage.Storage, userID int64) error {
func rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Renaming feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.String("new_title", title),
)
if title == "" {
return errors.New("empty title")
return errEmptyFeedTitle
}
feed, err := getFeed(stream, store, userID)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
if feed == nil {
return errFeedNotFound
}
feedModification := model.FeedModificationRequest{
Title: &title,
}
@@ -811,15 +551,29 @@ func rename(stream Stream, title string, store *storage.Storage, userID int64) e
return store.UpdateFeed(feed)
}
func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
feed, err := getFeed(stream, store, userID)
func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Moving feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.Any("label_stream", labelStream),
)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
category, err := getOrCreateCategory(destination, store, userID)
if feed == nil {
return errFeedNotFound
}
category, err := getOrCreateCategory(labelStream, store, userID)
if err != nil {
return err
}
if category == nil {
return errCategoryNotFound
}
feedModification := model.FeedModificationRequest{
CategoryID: &category.ID,
}
@@ -851,20 +605,20 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
return
}
streamIds, err := getStreams(r.Form[ParamStreamID], userID)
streamIds, err := getStreams(r.Form[paramStreamID], userID)
if err != nil || len(streamIds) == 0 {
json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
return
}
newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
newLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamTagsAdd))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramTagsAdd))
return
}
title := r.Form.Get(ParamTitle)
action := r.Form.Get(ParamSubscribeAction)
title := r.Form.Get(paramTitle)
action := r.Form.Get(paramSubscribeAction)
switch action {
case "subscribe":
@@ -882,32 +636,41 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
case "edit":
if title != "" {
if err := rename(streamIds[0], title, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
if r.Form.Has(ParamTagsAdd) {
if r.Form.Has(paramTagsAdd) {
if newLabel.Type != LabelStream {
json.BadRequest(w, r, errors.New("destination must be a label"))
return
}
if err := move(streamIds[0], newLabel, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
default:
json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
json.BadRequest(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
userName := request.UserName(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /stream/items/contents",
@@ -927,25 +690,20 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
json.ServerError(w, r, err)
return
}
var user *model.User
if user, err = h.store.UserByID(userID); err != nil {
json.ServerError(w, r, err)
return
}
requestModifiers, err := getStreamFilterModifiers(r)
requestModifiers, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
}
userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -969,30 +727,18 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
if len(entries) == 0 {
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
return
result := streamContentItemsResponse{
Direction: "ltr",
ID: "user/-/state/com.google/reading-list",
Title: "Reading List",
Updated: time.Now().Unix(),
Self: []contentHREF{{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
}},
Author: userName,
Items: make([]contentItem, len(entries)),
}
result := streamContentItems{
Direction: "ltr",
ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
Title: entries[0].Feed.Title,
Alternate: []contentHREFType{
{
HREF: entries[0].Feed.SiteURL,
Type: "text/html",
},
},
Updated: time.Now().Unix(),
Self: []contentHREF{
{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
},
},
Author: user.Username,
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
@@ -1001,7 +747,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories := make([]string, 0)
categories = append(categories, userReadingList)
if entry.Feed.Category.Title != "" {
categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title)
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
}
if entry.Status == model.EntryStatusRead {
categories = append(categories, userRead)
@@ -1012,15 +758,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
entry.Enclosures.ProxifyEnclosureURL(h.router)
contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID),
result.Items[i] = contentItem{
ID: convertEntryIDToLongFormItemID(entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
TimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),
CrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),
Published: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(),
Categories: categories,
@@ -1044,14 +789,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Content: entry.Content,
},
Origin: contentItemOrigin{
StreamID: fmt.Sprintf("feed/%d", entry.FeedID),
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
Enclosure: enclosures,
}
}
result.Items = contentItems
json.OK(w, r, result)
}
@@ -1072,9 +817,9 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
streams, err := getStreams(r.Form[ParamStreamID], userID)
streams, err := getStreams(r.Form[paramStreamID], userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
@@ -1093,7 +838,7 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
@@ -1112,15 +857,15 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
source, err := getStream(r.Form.Get(ParamStreamID), userID)
source, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
destination, err := getStream(r.Form.Get(ParamDestination), userID)
destination, err := getStream(r.Form.Get(paramDestination), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamDestination))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramDestination))
return
}
@@ -1143,21 +888,24 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
json.NotFound(w, r)
return
}
categoryRequest := model.CategoryRequest{
Title: destination.ID,
categoryModificationRequest := model.CategoryModificationRequest{
Title: model.SetOptionalField(destination.ID),
}
verr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest)
if verr != nil {
json.BadRequest(w, r, verr.Error())
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
json.BadRequest(w, r, validationError.Error())
return
}
categoryRequest.Patch(category)
err = h.store.UpdateCategory(category)
if err != nil {
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
@@ -1181,13 +929,13 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
result.Tags = make([]subscriptionCategory, 0)
result.Tags = append(result.Tags, subscriptionCategory{
ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
result.Tags = make([]subscriptionCategoryResponse, 0)
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
})
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategory{
ID: fmt.Sprintf(UserLabelPrefix, userID) + category.Title,
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
Label: category.Title,
Type: "folder",
})
@@ -1217,13 +965,13 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
return
}
result.Subscriptions = make([]subscription, 0)
result.Subscriptions = make([]subscriptionResponse, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscription{
ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID),
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: h.feedIconURL(feed),
})
@@ -1252,17 +1000,17 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, r, err)
return
}
userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
if user == nil {
json.NotFound(w, r)
return
}
userInfo := userInfoResponse{UserID: strconv.FormatInt(user.ID, 10), UserName: user.Username, UserProfileID: strconv.FormatInt(user.ID, 10), UserEmail: user.Username}
json.OK(w, r, userInfo)
}
@@ -1282,7 +1030,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
return
}
rm, err := getStreamFilterModifiers(r)
rm, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
@@ -1296,7 +1044,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
)
if len(rm.Streams) != 1 {
json.ServerError(w, r, fmt.Errorf("googlereader: only one stream type expected"))
json.ServerError(w, r, errors.New("googlereader: only one stream type expected"))
return
}
switch rm.Streams[0].Type {
@@ -1338,7 +1086,7 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("filter_type", s.Type),
slog.Int("filter_type", int(s.Type)),
)
}
}
@@ -1509,3 +1257,82 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /mark-all-as-read",
slog.String("handler", "markAllAsReadHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
json.BadRequest(w, r, err)
return
}
stream, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, err)
return
}
var before time.Time
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
if timestampParsedValue > 0 {
// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.
if len(timestampParamValue) >= 16 {
before = time.UnixMicro(timestampParsedValue)
} else {
before = time.Unix(timestampParsedValue, 0)
}
}
}
if before.IsZero() {
before = time.Now()
}
switch stream.Type {
case FeedStream:
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
err = h.store.MarkFeedAsRead(userID, feedID, before)
if err != nil {
json.ServerError(w, r, err)
return
}
case LabelStream:
category, err := h.store.CategoryByTitle(userID, stream.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {
json.ServerError(w, r, err)
return
}
case ReadingListStream:
if err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {
json.ServerError(w, r, err)
return
}
}
sendOkayResponse(w)
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
)
const (
ItemIDPrefix = "tag:google.com,2005:reader/item/"
ItemIDFormat = "tag:google.com,2005:reader/item/%016x"
)
func convertEntryIDToLongFormItemID(entryID int64) string {
// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.
return fmt.Sprintf(ItemIDFormat, entryID)
}
// Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
// NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
// Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
// Liferea uses this format: "12345" (decimal string)
// It returns the parsed ID as a int64 and an error if parsing fails.
func parseItemID(itemIDValue string) (int64, error) {
var itemID int64
if strings.HasPrefix(itemIDValue, ItemIDPrefix) {
n, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)
if err != nil {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: %w", itemIDValue, err)
}
if n != 1 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: expected 1 value, got %d", itemIDValue, n)
}
if itemID == 0 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: item ID is zero", itemIDValue)
}
return itemID, nil
}
if len(itemIDValue) == 16 {
if n, err := fmt.Sscanf(itemIDValue, "%016x", &itemID); err == nil && n == 1 {
return itemID, nil
}
}
itemID, err := strconv.ParseInt(itemIDValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse decimal item ID %s: %w", itemIDValue, err)
}
return itemID, nil
}
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[paramItemIDs]
if len(items) == 0 {
return nil, errors.New("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
itemID, err := parseItemID(item)
if err != nil {
return nil, fmt.Errorf("googlereader: failed to parse item ID %s: %w", item, err)
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"net/http"
"net/url"
"reflect"
"testing"
)
func TestConvertEntryIDToLongFormItemID(t *testing.T) {
entryID := int64(344691561)
expected := "tag:google.com,2005:reader/item/00000000148b9369"
result := convertEntryIDToLongFormItemID(entryID)
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestParseItemIDsFromRequest(t *testing.T) {
formValues := url.Values{}
formValues.Add("i", "12345")
formValues.Add("i", "tag:google.com,2005:reader/item/00000000148b9369")
formValues.Add("i", "tag:google.com,2005:reader/item/2f2")
formValues.Add("i", "000000000000046f")
formValues.Add("i", "tag:google.com,2005:reader/item/272")
request := &http.Request{
Form: formValues,
}
result, err := parseItemIDsFromRequest(request)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var expected = []int64{12345, 344691561, 754, 1135, 626}
if !reflect.DeepEqual(result, expected) {
t.Errorf("expected %v, got %v", expected, result)
}
// Test with no item IDs
formValues = url.Values{}
request = &http.Request{
Form: formValues,
}
_, err = parseItemIDsFromRequest(request)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestParseItemID(t *testing.T) {
// Test with long form ID and hex ID
result, err := parseItemID("tag:google.com,2005:reader/item/0000000000000001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := int64(1)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with hexadecimal long form ID
result, err = parseItemID("0000000000000468")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(1128)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with short form ID
result, err = parseItemID("12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(12345)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with invalid long form ID
_, err = parseItemID("tag:google.com,2005:reader/item/000000000000000g")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with invalid short form ID
_, err = parseItemID("invalid_id")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with empty ID
_, err = parseItemID("")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
+14 -34
View File
@@ -25,19 +25,6 @@ func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) handleCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
@@ -51,7 +38,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
@@ -62,7 +49,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
} else {
@@ -74,7 +61,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
fields := strings.Fields(authorization)
@@ -84,7 +71,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if fields[0] != "GoogleLogin" {
@@ -93,7 +80,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
auths := strings.Split(fields[1], "=")
@@ -103,7 +90,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if auths[0] != "auth" {
@@ -112,7 +99,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
token = auths[1]
@@ -126,7 +113,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
var integration *model.Integration
@@ -139,7 +126,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
@@ -149,7 +136,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
@@ -159,7 +146,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
@@ -169,26 +156,19 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderToken, token)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// paramItemIDs - name of the parameter with the item ids
paramItemIDs = "i"
// paramStreamID - name of the parameter containing the stream to be included
paramStreamID = "s"
// paramStreamExcludes - name of the parameter containing streams to be excluded
paramStreamExcludes = "xt"
// paramStreamFilters - name of the parameter containing streams to be included
paramStreamFilters = "it"
// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned
paramStreamMaxItems = "n"
// paramStreamOrder - name of the parameter containing the sort criteria
paramStreamOrder = "r"
// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
paramStreamStartTime = "ot"
// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
paramStreamStopTime = "nt"
// paramTagsRemove - name of the parameter containing tags (streams) to be removed
paramTagsRemove = "r"
// paramTagsAdd - name of the parameter containing tags (streams) to be added
paramTagsAdd = "a"
// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit
paramSubscribeAction = "ac"
// paramTitle - name of the parameter for the title of the subscription
paramTitle = "t"
// paramQuickAdd - name of the parameter for a URL being quick subscribed to
paramQuickAdd = "quickadd"
// paramDestination - name of the parameter for the new name of a tag
paramDestination = "dest"
// paramContinuation - name of the parameter for callers to pass to receive the next page of results
paramContinuation = "c"
// paramTimestamp - name of the parameter for unix timestamp
paramTimestamp = "ts"
)
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// streamPrefix is the prefix for streams (read/starred/reading list and so on)
streamPrefix = "user/-/state/com.google/"
// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
userStreamPrefix = "user/%d/state/com.google/"
// labelPrefix is the prefix for a label stream
labelPrefix = "user/-/label/"
// userLabelPrefix is the user specific prefix prefix for a label stream
userLabelPrefix = "user/%d/label/"
// feedPrefix is the prefix for a feed stream
feedPrefix = "feed/"
// readStreamSuffix is the suffix for read stream
readStreamSuffix = "read"
// starredStreamSuffix is the suffix for starred stream
starredStreamSuffix = "starred"
// readingListStreamSuffix is the suffix for reading list stream
readingListStreamSuffix = "reading-list"
// keptUnreadStreamSuffix is the suffix for kept unread stream
keptUnreadStreamSuffix = "kept-unread"
// broadcastStreamSuffix is the suffix for broadcast stream
broadcastStreamSuffix = "broadcast"
// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream
broadcastFriendsStreamSuffix = "broadcast-friends"
// likeStreamSuffix is the suffix for like stream
likeStreamSuffix = "like"
)
+91
View File
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strings"
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
streamStr := make([]string, 0, len(r.Streams))
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
exclusions := make([]string, 0, len(r.ExcludeTargets))
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
filters := make([]string, 0, len(r.FilterTargets))
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, "Sort Direction: "+r.SortDirection)
results = append(results, "Continuation Token: "+r.ContinuationToken)
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, paramContinuation, 0)
result.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))
return result, nil
}
+33 -41
View File
@@ -6,34 +6,36 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"miniflux.app/v2/internal/http/response"
)
type login struct {
type loginResponse struct {
SID string `json:"SID,omitempty"`
LSID string `json:"LSID,omitempty"`
Auth string `json:"Auth,omitempty"`
}
func (l login) String() string {
func (l loginResponse) String() string {
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
}
type userInfo struct {
type userInfoResponse struct {
UserID string `json:"userId"`
UserName string `json:"userName"`
UserProfileID string `json:"userProfileId"`
UserEmail string `json:"userEmail"`
}
type subscription struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategory `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
type subscriptionResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategoryResponse `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
}
type subscriptionsResponse struct {
Subscriptions []subscriptionResponse `json:"subscriptions"`
}
type quickAddResponse struct {
@@ -43,14 +45,11 @@ type quickAddResponse struct {
StreamName string `json:"streamName,omitempty"`
}
type subscriptionCategory struct {
type subscriptionCategoryResponse struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
}
type subscriptionsResponse struct {
Subscriptions []subscription `json:"subscriptions"`
}
type itemRef struct {
ID string `json:"id"`
@@ -64,18 +63,17 @@ type streamIDResponse struct {
}
type tagsResponse struct {
Tags []subscriptionCategory `json:"tags"`
Tags []subscriptionCategoryResponse `json:"tags"`
}
type streamContentItems struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Alternate []contentHREFType `json:"alternate"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
type streamContentItemsResponse struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
}
type contentItem struct {
@@ -119,21 +117,15 @@ type contentItemOrigin struct {
HTMLUrl string `json:"htmlUrl"`
}
// Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", "text/plain")
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithBody("Unauthorized")
builder.Write()
func sendUnauthorizedResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Reader-Google-Bad-Token", "true")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
}
// OK sends a ok response to the client.
func OK(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusOK)
builder.WithHeader("Content-Type", "text/plain")
builder.WithBody("OK")
builder.Write()
func sendOkayResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
+119
View File
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"strings"
)
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, feedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID)), strings.HasPrefix(streamID, streamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID))
id = strings.TrimPrefix(id, streamPrefix)
switch id {
case readStreamSuffix:
return Stream{ReadStream, ""}, nil
case starredStreamSuffix:
return Stream{StarredStream, ""}, nil
case readingListStreamSuffix:
return Stream{ReadingListStream, ""}, nil
case keptUnreadStreamSuffix:
return Stream{KeptUnreadStream, ""}, nil
case broadcastStreamSuffix:
return Stream{BroadcastStream, ""}, nil
case broadcastFriendsStreamSuffix:
return Stream{BroadcastFriendsStream, ""}, nil
case likeStreamSuffix:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
case strings.HasPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID)), strings.HasPrefix(streamID, labelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID))
id = strings.TrimPrefix(id, labelPrefix)
return Stream{LabelStream, id}, nil
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0, len(streamIDs))
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
+1 -1
View File
@@ -24,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
SameSite: http.SameSiteLaxMode,
}
}
+37 -10
View File
@@ -9,19 +9,46 @@ import (
"strings"
)
// IsTrustedIP checks if the given remote IP belongs to one of the trusted networks.
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
if remoteIP == "@" || strings.HasPrefix(remoteIP, "/") {
return true
}
ip := net.ParseIP(remoteIP)
if ip == nil {
return false
}
for _, cidr := range trustedNetworks {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
headers := []string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
if isTrustedProxyClient {
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
address = dropIPv6zone(address)
if net.ParseIP(address) != nil {
return address
if net.ParseIP(address) != nil {
return address
}
}
}
}
+46 -14
View File
@@ -10,27 +10,27 @@ import (
func TestFindClientIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -41,7 +41,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -50,7 +50,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -59,7 +59,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -68,7 +68,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "70.41.3.18" {
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
@@ -77,7 +77,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.0.1" {
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -87,7 +87,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "192.168.122.1" {
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -99,7 +99,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -107,7 +107,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
r := &http.Request{RemoteAddr: "@"}
if ip := FindClientIP(r); ip != "@" {
if ip := FindClientIP(r, false); ip != "@" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
@@ -119,7 +119,39 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "@", Header: headers}
if ip := FindClientIP(r); ip != "203.0.113.195" {
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestIsTrustedIP(t *testing.T) {
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
scenarios := []struct {
ip string
expected bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"::1", true},
{"192.168.1.1", false},
{"invalid", false},
{"@", true},
{"/tmp/miniflux.sock", true},
}
for _, scenario := range scenarios {
result := IsTrustedIP(scenario.ip, trustedNetworks)
if result != scenario.expected {
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
}
}
if IsTrustedIP("127.0.0.1", nil) {
t.Error("Expected false when no trusted networks are defined")
}
if IsTrustedIP("127.0.0.1", []string{}) {
t.Error("Expected false when trusted networks list is empty")
}
}
+18 -13
View File
@@ -6,6 +6,7 @@ package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/model"
)
@@ -16,6 +17,7 @@ type ContextKey int
// List of context keys.
const (
UserIDContextKey ContextKey = iota
UserNameContextKey
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
@@ -28,10 +30,9 @@ const (
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
PocketRequestTokenContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderToken
GoogleReaderTokenKey
WebAuthnDataContextKey
)
@@ -44,9 +45,9 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
return nil
}
// GoolgeReaderToken returns the google reader token if it exists.
func GoolgeReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderToken)
// GoogleReaderToken returns the google reader token if it exists.
func GoogleReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderTokenKey)
}
// IsAdminUser checks if the logged user is administrator.
@@ -64,6 +65,15 @@ func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
value = "unknown"
}
return value
}
// UserTimezone returns the timezone used by the logged user.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
@@ -125,19 +135,14 @@ func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// PocketRequestToken returns the Pocket Request Token if any.
func PocketRequestToken(r *http.Request) string {
return getContextStringValue(r, PocketRequestTokenContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) int64 {
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return 0
return time.Time{}
}
return timestamp
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the context.
-22
View File
@@ -390,28 +390,6 @@ func TestFlashErrorMessage(t *testing.T) {
}
}
func TestPocketRequestToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := PocketRequestToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
r = r.WithContext(ctx)
result = PocketRequestToken(r)
expected = "request token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
+4 -7
View File
@@ -6,7 +6,6 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"net/http"
@@ -48,7 +47,7 @@ func (b *Builder) WithBody(body any) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = fmt.Sprintf("attachment; filename=%s", filename)
b.headers["Content-Disposition"] = "attachment; filename=" + filename
return b
}
@@ -85,8 +84,6 @@ func (b *Builder) Write() {
b.compress(v)
case string:
b.compress([]byte(v))
case error:
b.compress([]byte(v.Error()))
case io.Reader:
// Compression not implemented in this case
b.writeHeaders()
@@ -118,24 +115,24 @@ func (b *Builder) compress(data []byte) {
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
brotliWriter.Close()
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
defer gzipWriter.Close()
gzipWriter.Write(data)
gzipWriter.Close()
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
defer flateWriter.Close()
flateWriter.Write(data)
flateWriter.Close()
return
}
}
-22
View File
@@ -4,7 +4,6 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -105,27 +104,6 @@ func TestBuildResponseWithAttachment(t *testing.T) {
}
}
func TestBuildResponseWithError(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(errors.New("Some error")).Write()
})
handler.ServeHTTP(w, r)
expectedBody := `Some error`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
}
func TestBuildResponseWithByteBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
+6 -5
View File
@@ -4,6 +4,7 @@
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"html"
"log/slog"
"net/http"
@@ -12,7 +13,7 @@ import (
)
// OK creates a new HTML response with a 200 status code.
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
@@ -38,9 +39,9 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
@@ -62,9 +63,9 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
+6 -6
View File
@@ -58,7 +58,7 @@ func TestServerErrorResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("Some error"))
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -69,13 +69,13 @@ func TestServerErrorResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
@@ -91,7 +91,7 @@ func TestBadRequestResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some error"))
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -102,13 +102,13 @@ func TestBadRequestResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
+2 -2
View File
@@ -10,7 +10,7 @@ import (
)
// OK writes a standard XML response with a status 200 OK.
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBody(body)
@@ -18,7 +18,7 @@ func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
}
// Attachment forces the XML document to be downloaded by the web browser.
func Attachment(w http.ResponseWriter, r *http.Request, filename string, body interface{}) {
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
+147 -125
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
@@ -12,7 +12,6 @@ import (
"os"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/api"
"miniflux.app/v2/internal/config"
@@ -30,36 +29,84 @@ 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 {
listenAddresses := config.Opts.ListenAddr()
var httpServers []*http.Server
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
listenAddr := config.Opts.ListenAddr()
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
var sharedAutocertTLSConfig *tls.Config
if certDomain != "" {
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
sharedAutocertTLSConfig = &tls.Config{}
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.SetHTTPSValue(true)
httpServers = append(httpServers, challengeServer)
}
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
startSystemdSocketServer(server)
case strings.HasPrefix(listenAddr, "/"):
startUnixSocketServer(server, listenAddr)
case certDomain != "":
config.Opts.HTTPS = true
startAutoCertTLSServer(server, certDomain, store)
case certFile != "" && keyFile != "":
config.Opts.HTTPS = true
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
default:
server.Addr = listenAddr
startHTTPServer(server)
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
Handler: setupHandler(store, pool),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
shouldAddServer = false
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.SetHTTPSValue(true)
default:
server.Addr = listenAddr
startHTTPServer(server)
}
if shouldAddServer {
httpServers = append(httpServers, server)
}
}
return server
return httpServers
}
func startSystemdSocketServer(server *http.Server) {
@@ -72,83 +119,66 @@ func startSystemdSocketServer(server *http.Server) {
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
os.Remove(socketFile)
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
go func(sock string) {
listener, err := net.Listen("unix", sock)
if err != nil {
printErrorAndExit(`Server failed to start: %v`, err)
}
defer listener.Close()
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
if err := os.Chmod(sock, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission: %v`, err)
}
go func() {
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
if certFile != "" && keyFile != "" {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
// Ensure HTTPS is marked as true if any listener uses TLS
config.Opts.SetHTTPSValue(true)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
} else {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}
}(socketFile)
}()
}
func tlsConfig() *tls.Config {
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
// And https://wiki.mozilla.org/Security/Server_Side_TLS
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
}
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
server.Addr = ":https"
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
server.TLSConfig = tlsConfig()
server.TLSConfig.GetCertificate = certManager.GetCertificate
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
slog.String("domain", certDomain),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
server.TLSConfig = tlsConfig()
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
@@ -156,7 +186,7 @@ func startTLSServer(server *http.Server, certFile, keyFile string) {
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
@@ -167,49 +197,66 @@ func startHTTPServer(server *http.Server) {
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
router := mux.NewRouter()
// These routes do not take the base path into consideration and are always available at the root of the server.
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
var subrouter *mux.Router
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
} else {
subrouter = router.NewRoute().Subrouter()
}
if config.Opts.HasMaintenanceMode() {
router.Use(func(next http.Handler) http.Handler {
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
router.Use(middleware)
subrouter.Use(middleware)
fever.Serve(router, store)
googlereader.Serve(router, store)
api.Serve(router, store, pool)
ui.Serve(router, store, pool)
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
if config.Opts.HasAPI() {
api.Serve(subrouter, store, pool)
}
ui.Serve(subrouter, store, pool)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
return
}
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
w.Write([]byte("OK"))
}).Name("healthcheck")
router.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
router.Handle("/metrics", promhttp.Handler()).Name("metrics")
router.Use(func(next http.Handler) http.Handler {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
@@ -269,32 +316,7 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
}
remoteIP := request.FindRemoteIP(r)
if remoteIP == "@" {
// This indicates a request sent via a Unix socket, always consider these trusted.
return true
}
for _, cidr := range config.Opts.MetricsAllowedNetworks() {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
slog.Error("Metrics endpoint accessed with invalid CIDR",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("client_user_agent", r.UserAgent()),
slog.String("client_remote_addr", r.RemoteAddr),
slog.String("cidr", cidr),
)
return false
}
// We use r.RemoteAddr in this case because HTTP headers like X-Forwarded-For can be easily spoofed.
// The recommendation is to use HTTP Basic authentication.
if network.Contains(net.ParseIP(remoteIP)) {
return true
}
}
return false
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
}
func printErrorAndExit(format string, a ...any) {
+7 -5
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"context"
@@ -15,12 +15,14 @@ import (
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.FindClientIP(r)
remoteIP := request.FindRemoteIP(r)
isTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())
clientIP := request.FindClientIP(r, isTrustedProxyClientIP)
ctx := r.Context()
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.HTTPS = true
if isTrustedProxyClientIP && r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.SetHTTPSValue(true)
}
t1 := time.Now()
@@ -36,7 +38,7 @@ func middleware(next http.Handler) http.Handler {
)
}()
if config.Opts.HTTPS && config.Opts.HasHSTS() {
if config.Opts.HTTPS() && config.Opts.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
+2 -1
View File
@@ -6,6 +6,7 @@ package apprise
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -29,7 +30,7 @@ func NewClient(serviceURL, baseURL string) *Client {
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
if c.baseURL == "" || c.servicesURL == "" {
return fmt.Errorf("apprise: missing base URL or services URL")
return errors.New("apprise: missing base URL or services URL")
}
for _, entry := range entries {
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package archiveorg
import (
"log/slog"
"net/http"
"net/url"
)
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
const options = "delay_wb_availability=1&if_not_archived_within=15d"
type Client struct{}
func NewClient() *Client {
return &Client{}
}
func (c *Client) SendURL(entryURL, title string) {
// We're using a goroutine here as submissions to archive.org might take a long time
// and trigger a timeout on miniflux' side.
go func(entryURL string) {
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
if err != nil {
slog.Error("archiveorg: unable to send request: %v",
slog.Any("err", err),
slog.String("title", title),
slog.String("url", entryURL),
)
return
}
if res.StatusCode > 299 {
slog.Error("archiveorg: failed with status code",
slog.String("title", title),
slog.String("url", entryURL),
slog.Int("code", res.StatusCode),
)
}
res.Body.Close()
}(entryURL)
}
+4 -1
View File
@@ -1,4 +1,7 @@
package betula
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package betula // import "miniflux.app/v2/internal/integration/betula"
import (
"fmt"
+2 -1
View File
@@ -6,6 +6,7 @@ package espial // import "miniflux.app/v2/internal/integration/espial"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -27,7 +28,7 @@ func NewClient(baseURL, apiKey string) *Client {
func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("espial: missing base URL or API key")
return errors.New("espial: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
@@ -4,6 +4,7 @@
package instapaper // import "miniflux.app/v2/internal/integration/instapaper"
import (
"errors"
"fmt"
"net/http"
"net/url"
@@ -25,7 +26,7 @@ func NewClient(username, password string) *Client {
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.username == "" || c.password == "" {
return fmt.Errorf("instapaper: missing username or password")
return errors.New("instapaper: missing username or password")
}
values := url.Values{}
+109 -31
View File
@@ -6,15 +6,17 @@ package integration // import "miniflux.app/v2/internal/integration"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/archiveorg"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/karakeep"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linktaco"
"miniflux.app/v2/internal/integration/linkwarden"
"miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion"
@@ -22,7 +24,6 @@ import (
"miniflux.app/v2/internal/integration/nunuxkeeper"
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/pushover"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
@@ -108,6 +109,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
if userIntegrations.WallabagEnabled {
slog.Debug("Sending entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
@@ -118,12 +120,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
userIntegrations.WallabagClientSecret,
userIntegrations.WallabagUsername,
userIntegrations.WallabagPassword,
userIntegrations.WallabagTags,
userIntegrations.WallabagOnlyURL,
)
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
@@ -196,24 +200,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.PocketEnabled {
slog.Debug("Sending entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkAceEnabled {
slog.Debug("Sending entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
@@ -261,19 +247,21 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.LinkwardenEnabled {
slog.Debug("Sending entry to linkwarden",
if userIntegrations.LinktacoEnabled {
slog.Debug("Sending entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
client := linktaco.NewClient(
userIntegrations.LinktacoAPIToken,
userIntegrations.LinktacoOrgSlug,
userIntegrations.LinktacoTags,
userIntegrations.LinktacoVisibility,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Linkwarden",
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
@@ -282,6 +270,30 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.LinkwardenEnabled {
attrs := []any{
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
}
if userIntegrations.LinkwardenCollectionID != nil {
attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionID))
}
slog.Debug("Sending entry to linkwarden", attrs...)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
userIntegrations.LinkwardenCollectionID,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
attrs = append(attrs, slog.Any("error", err))
slog.Error("Unable to send entry to Linkwarden", attrs...)
}
}
if userIntegrations.ReadeckEnabled {
slog.Debug("Sending entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
@@ -390,21 +402,38 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.ArchiveorgEnabled {
slog.Debug("Sending entry to archive.org",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
webhookURL = entry.Feed.WebhookURL
} else {
webhookURL = userIntegrations.WebhookURL
}
slog.Debug("Sending entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {
slog.Error("Unable to send entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
slog.Any("error", err),
)
}
@@ -428,6 +457,30 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.KarakeepEnabled {
slog.Debug("Sending entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.KarakeepTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := karakeep.NewClient(
userIntegrations.KarakeepAPIKey,
userIntegrations.KarakeepURL,
userIntegrations.KarakeepTags,
)
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.KarakeepTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
@@ -473,7 +526,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
)
}
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if feed.WebhookURL != "" {
@@ -633,4 +685,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
}
// Push each new entry to Readeck when push is enabled
if userIntegrations.ReadeckPushEnabled {
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
for _, entry := range entries {
slog.Debug("Sending a new entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bytes"
"log/slog"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
coll := int64(12345)
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenCollectionID: &coll,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if !strings.Contains(out, `"collection_id":12345`) {
t.Fatalf("expected collection_id in logs; got: %s", out)
}
}
func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, nil)
logger := slog.New(handler)
prev := slog.Default()
slog.SetDefault(logger)
defer slog.SetDefault(prev)
entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
userIntegrations := &model.Integration{
UserID: 1,
LinkwardenEnabled: true,
LinkwardenURL: "",
LinkwardenAPIKey: "",
}
SendEntry(entry, userIntegrations)
out := buf.String()
if strings.Contains(out, "collection_id") {
t.Fatalf("did not expect collection_id in logs; got: %s", out)
}
}
+163
View File
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
tags string
}
type tagItem struct {
TagName string `json:"tagName"`
}
type saveURLPayload struct {
Type string `json:"type"`
URL string `json:"url"`
}
type saveURLResponse struct {
ID string `json:"id"`
}
type attachTagsPayload struct {
Tags []tagItem `json:"tags"`
}
type errorResponse struct {
Code string `json:"code"`
Error string `json:"error"`
}
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
}
func (c *Client) attachTags(entryID string) error {
if c.tags == "" {
return nil
}
tagItems := make([]tagItem, 0)
for tag := range strings.SplitSeq(c.tags, ",") {
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
}
}
if len(tagItems) == 0 {
return nil
}
tagRequestBody, err := json.Marshal(&attachTagsPayload{
Tags: tagItems,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode tag request body: %v", err)
}
tagRequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s/tags", c.apiEndpoint, entryID), bytes.NewReader(tagRequestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create tag request: %v", err)
}
tagRequest.Header.Set("Authorization", "Bearer "+c.apiToken)
tagRequest.Header.Set("Content-Type", "application/json")
tagRequest.Header.Set("User-Agent", "Miniflux/"+version.Version)
tagResponse, err := c.wrapped.Do(tagRequest)
if err != nil {
return fmt.Errorf("karakeep: unable to send tag request: %v", err)
}
defer tagResponse.Body.Close()
if tagResponse.StatusCode != http.StatusOK && tagResponse.StatusCode != http.StatusCreated {
tagResponseBody, err := io.ReadAll(tagResponse.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse tag response: %s", err)
}
var errResponse errorResponse
if err := json.Unmarshal(tagResponseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse tag error response: status=%d body=%s", tagResponse.StatusCode, string(tagResponseBody))
}
return fmt.Errorf("karakeep: failed to attach tags: status=%d errorcode=%s %s", tagResponse.StatusCode, errResponse.Code, errResponse.Error)
}
return nil
}
func (c *Client) SaveURL(entryURL string) error {
requestBody, err := json.Marshal(&saveURLPayload{
Type: "link",
URL: entryURL,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
resp, err := c.wrapped.Do(req)
if err != nil {
return fmt.Errorf("karakeep: unable to send request: %v", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse response: %s", err)
}
if resp.Header.Get("Content-Type") != "application/json" {
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
}
if resp.StatusCode != http.StatusCreated {
var errResponse errorResponse
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
}
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
}
var response saveURLResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return fmt.Errorf("karakeep: unable to parse response: %v", err)
}
if response.ID == "" {
return errors.New("karakeep: unable to get ID from response")
}
if err := c.attachTags(response.ID); err != nil {
return fmt.Errorf("karakeep: unable to attach tags: %v", err)
}
return nil
}
+6 -2
View File
@@ -1,8 +1,12 @@
package linkace
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkace // import "miniflux.app/v2/internal/integration/linkace"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -28,7 +32,7 @@ func NewClient(baseURL, apiKey, tags string, private bool, checkDisabled bool) *
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkace: missing base URL or API key")
return errors.New("linkace: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
+2 -1
View File
@@ -6,6 +6,7 @@ package linkding // import "miniflux.app/v2/internal/integration/linkding"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -30,7 +31,7 @@ func NewClient(baseURL, apiKey, tags string, unread bool) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkding: missing base URL or API key")
return errors.New("linkding: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
+147
View File
@@ -0,0 +1,147 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultGraphQLURL = "https://api.linktaco.com/query"
maxTags = 10
maxDescriptionLength = 500
)
type Client struct {
graphqlURL string
apiToken string
orgSlug string
tags string
visibility string
}
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
if visibility == "" {
visibility = "PUBLIC"
}
return &Client{
graphqlURL: defaultGraphQLURL,
apiToken: apiToken,
orgSlug: orgSlug,
tags: tags,
visibility: visibility,
}
}
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
if c.apiToken == "" || c.orgSlug == "" {
return errors.New("linktaco: missing API token or organization slug")
}
description := entryContent
if len(description) > maxDescriptionLength {
description = description[:maxDescriptionLength]
}
// tags (limit to 10)
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
return c == ',' || c == ' '
})
if len(tags) > maxTags {
tags = tags[:maxTags]
}
// tagsStr is used in GraphQL query to pass comma separated tags
tagsStr := strings.Join(tags, ",")
mutation := `
mutation AddLink($input: LinkInput!) {
addLink(input: $input) {
id
url
title
}
}
`
variables := map[string]any{
"input": map[string]any{
"url": entryURL,
"title": entryTitle,
"description": description,
"orgSlug": c.orgSlug,
"visibility": c.visibility,
"unread": true,
"starred": false,
"archive": false,
"tags": tagsStr,
},
}
requestBody, err := json.Marshal(map[string]any{
"query": mutation,
"variables": variables,
})
if err != nil {
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linktaco: 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.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
}
var graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []json.RawMessage `json:"errors"`
}
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
return fmt.Errorf("linktaco: unable to decode response: %v", err)
}
if len(graphqlResponse.Errors) > 0 {
// Try to extract error message
var errorMsg string
for _, errJSON := range graphqlResponse.Errors {
var errObj struct {
Message string `json:"message"`
}
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
errorMsg = errObj.Message
break
}
}
if errorMsg == "" {
// Fallback. Should never be reached.
errorMsg = "GraphQL error occurred (fallback message)"
}
return fmt.Errorf("linktaco: %s", errorMsg)
}
return nil
}
@@ -0,0 +1,440 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateBookmark(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1, tag2",
visibility: "PUBLIC",
entryURL: "https://example.com",
entryTitle: "Test Article",
entryContent: "Test content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify mutation exists
if _, ok := req["query"]; !ok {
t.Error("Missing 'query' field in request")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
},
},
})
},
wantErr: false,
},
{
name: "missing API token",
apiToken: "",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when API token is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "missing organization slug",
apiToken: "test-token",
orgSlug: "",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when org slug is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "GraphQL error response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "Invalid input",
},
},
})
},
wantErr: true,
errContains: "Invalid input",
},
{
name: "HTTP error status",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
},
wantErr: true,
errContains: "status=401",
},
{
name: "private visibility permission error",
apiToken: "test-token",
orgSlug: "test-org",
visibility: "PRIVATE",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
})
},
wantErr: true,
errContains: "PRIVATE visibility requires a paid LinkTaco account",
},
{
name: "content truncation",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
description := input["description"].(string)
if len(description) != maxDescriptionLength {
t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "tag limiting",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
if tagCount != maxTags {
t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "invalid JSON response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
},
wantErr: true,
errContains: "unable to decode response",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server if we have a server response function
var serverURL string
if tt.serverResponse != nil {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
serverURL = server.URL
}
// Create client with test server URL
client := &Client{
graphqlURL: serverURL,
apiToken: tt.apiToken,
orgSlug: tt.orgSlug,
tags: tt.tags,
visibility: tt.visibility,
}
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
expectedVisibility string
}{
{
name: "with all parameters",
apiToken: "token",
orgSlug: "org",
tags: "tag1,tag2",
visibility: "PRIVATE",
expectedVisibility: "PRIVATE",
},
{
name: "empty visibility defaults to PUBLIC",
apiToken: "token",
orgSlug: "org",
tags: "tag1",
visibility: "",
expectedVisibility: "PUBLIC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
if client.apiToken != tt.apiToken {
t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
}
if client.orgSlug != tt.orgSlug {
t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
}
if client.tags != tt.tags {
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
}
if client.visibility != tt.expectedVisibility {
t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
}
if client.graphqlURL != defaultGraphQLURL {
t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
}
})
}
}
func TestGraphQLMutation(t *testing.T) {
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
// Verify mutation structure
query, ok := req["query"].(string)
if !ok {
t.Fatal("Missing query field")
}
// Check that mutation contains expected parts
if !strings.Contains(query, "mutation AddLink") {
t.Error("Mutation should contain 'mutation AddLink'")
}
if !strings.Contains(query, "$input: LinkInput!") {
t.Error("Mutation should contain input parameter")
}
if !strings.Contains(query, "addLink(input: $input)") {
t.Error("Mutation should contain addLink call")
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
if !ok {
t.Fatal("Missing input in variables")
}
// Check all required fields
requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
for _, field := range requiredFields {
if _, ok := input[field]; !ok {
t.Errorf("Missing required field: %s", field)
}
}
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "test",
visibility: "PUBLIC",
}
err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3",
visibility: "PUBLIC",
}
// Run benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
}
}
func BenchmarkTagProcessing(b *testing.B) {
// Benchmark tag splitting and limiting
tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
b.ResetTimer()
for i := 0; i < b.N; i++ {
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
splitTags := strings.FieldsFunc(tags, tagsSplitFn)
if len(splitTags) > maxTags {
splitTags = splitTags[:maxTags]
}
_ = strings.Join(splitTags, ",")
}
}
+35 -23
View File
@@ -6,7 +6,9 @@ package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
@@ -17,17 +19,28 @@ import (
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
baseURL string
apiKey string
collectionID *int64
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
type linkwardenCollection struct {
ID *int64 `json:"id"`
}
type linkwardenRequest struct {
URL string `json:"url"`
Name string `json:"name"`
Collection *linkwardenCollection `json:"collection,omitempty"`
}
func NewClient(baseURL, apiKey string, collectionID *int64) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, collectionID: collectionID}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkwarden: missing base URL or API key")
return errors.New("linkwarden: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
@@ -35,13 +48,16 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkwardenBookmark{
Url: entryURL,
Name: "",
Description: "",
Tags: []string{},
Collection: map[string]interface{}{},
})
payload := linkwardenRequest{
URL: entryURL,
Name: entryTitle,
}
if c.collectionID != nil {
payload.Collection = &linkwardenCollection{ID: c.collectionID}
}
requestBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
@@ -54,8 +70,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: c.apiKey})
request.AddCookie(&http.Cookie{Name: "next-auth.session-token", Value: c.apiKey})
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
@@ -64,17 +79,14 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("linkwarden: unable to read response body: %v", err)
}
if response.StatusCode >= 400 {
return fmt.Errorf("linkwarden: unable to create link: url=%s status=%d", apiEndpoint, response.StatusCode)
return fmt.Errorf("linkwarden: unable to create link: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
type linkwardenBookmark struct {
Url string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Collection map[string]interface{} `json:"collection"`
}
@@ -0,0 +1,325 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkwarden
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"miniflux.app/v2/internal/model"
)
func TestCreateBookmark(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
collectionID *int64
entryURL string
entryTitle string
serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation without collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test Article",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article" {
t.Errorf("Expected name 'Test Article', got %v", reqName)
}
// Verify collection is not present when nil
if _, ok := req["collection"]; ok {
t.Error("Expected collection field to be omitted when collectionId is nil")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "123",
"url": "https://example.com",
"name": "Test Article",
})
},
wantErr: false,
},
{
name: "successful bookmark creation with collection",
baseURL: "",
apiKey: "test-api-key",
collectionID: model.OptionalNumber(int64(42)),
entryURL: "https://example.com/article",
entryTitle: "Test Article With Collection",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify URL
if reqURL := req["url"]; reqURL != "https://example.com/article" {
t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
}
// Verify title/name
if reqName := req["name"]; reqName != "Test Article With Collection" {
t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
}
// Verify collection is present and correct
if collection, ok := req["collection"]; ok {
collectionMap, ok := collection.(map[string]any)
if !ok {
t.Error("Expected collection to be a map")
}
if collectionID, ok := collectionMap["id"]; ok {
// JSON numbers are float64
if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
t.Errorf("Expected collection id 42, got %v", collectionID)
}
} else {
t.Error("Expected collection to have 'id' field")
}
} else {
t.Error("Expected collection field to be present when collectionId is set")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"id": "124",
"url": "https://example.com/article",
"name": "Test Article With Collection",
})
},
wantErr: false,
},
{
name: "missing API key",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when API key is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "server error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Internal server error"}`))
},
wantErr: true,
errContains: "unable to create link: status=500",
},
{
name: "bad request with null collection id error",
baseURL: "",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
},
wantErr: true,
errContains: "unable to create link: status=400",
},
{
name: "unauthorized",
baseURL: "",
apiKey: "invalid-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
},
wantErr: true,
errContains: "unable to create link: status=401",
},
{
name: "invalid base URL",
baseURL: ":",
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is invalid")
},
wantErr: true,
errContains: "invalid API endpoint",
},
{
name: "missing base URL",
baseURL: "",
apiKey: "",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called
t.Error("Server should not be called when base URL is missing")
},
wantErr: true,
errContains: "missing base URL or API key",
},
{
name: "network connection error",
baseURL: "http://localhost:1", // Invalid port that should fail to connect
apiKey: "test-api-key",
collectionID: nil,
entryURL: "https://example.com",
entryTitle: "Test",
serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
// Should not be called due to connection failure
t.Error("Server should not be called when connection fails")
},
wantErr: true,
errContains: "unable to send request",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
var server *httptest.Server
if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tt.serverResponse(w, r, t, tt.collectionID)
}))
defer server.Close()
}
// Use test server URL if baseURL is empty and we have a server
baseURL := tt.baseURL
if baseURL == "" && server != nil {
baseURL = server.URL
}
// Create client
client := NewClient(baseURL, tt.apiKey, tt.collectionID)
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
// Check error
if tt.wantErr {
if err == nil {
t.Error("Expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
baseURL string
apiKey string
collectionID *int64
}{
{
name: "client without collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: nil,
},
{
name: "client with collection",
baseURL: "https://linkwarden.example.com",
apiKey: "test-key",
collectionID: model.OptionalNumber(int64(123)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
if client.baseURL != tt.baseURL {
t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
}
if client.apiKey != tt.apiKey {
t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
}
if tt.collectionID == nil {
if client.collectionID != nil {
t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
}
} else {
if client.collectionID == nil {
t.Error("Expected collectionId to be set, got nil")
} else if *client.collectionID != *tt.collectionID {
t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
}
}
})
}
}

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