Compare commits

...

32 Commits

Author SHA1 Message Date
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
114 changed files with 2917 additions and 497 deletions
+3 -3
View File
@@ -40,10 +40,10 @@ jobs:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
- 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
-5
View File
@@ -30,11 +30,6 @@ jobs:
with:
go-version: stable
- uses: golangci/golangci-lint-action@v8
with:
args: >
--timeout 10m
--disable errcheck
--enable sqlclosecheck,misspell,whitespace,gocritic
- name: Run gofmt linter
run: gofmt -d -e .
+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
+2 -2
View File
@@ -100,8 +100,8 @@ test:
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;'
+441 -59
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,12 +251,26 @@ 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) {
body, err := c.request.Get("/v1/api-keys")
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
}
@@ -198,7 +286,14 @@ func (c *Client) APIKeys() (APIKeys, error) {
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
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 {
@@ -216,18 +311,39 @@ func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
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
}
@@ -246,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
}
@@ -262,7 +385,14 @@ 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
}
@@ -278,7 +408,14 @@ func (c *Client) Categories() (Categories, error) {
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCounters() (Categories, error) {
body, err := c.request.Get("/v1/categories?counts=true")
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
}
@@ -294,7 +431,14 @@ func (c *Client) CategoriesWithCounters() (Categories, error) {
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
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 {
@@ -312,7 +456,14 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
// CreateCategoryWithOptions creates a new category with options.
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
body, err := c.request.Post("/v1/categories", createRequest)
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
}
@@ -327,7 +478,14 @@ func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationReques
// 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), &CategoryModificationRequest{
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 {
@@ -345,7 +503,14 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
// UpdateCategoryWithOptions updates a category with options.
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
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
}
@@ -361,13 +526,27 @@ func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *Ca
// 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
}
@@ -383,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
}
@@ -410,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
}
@@ -426,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
}
@@ -448,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
}
@@ -468,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
}
@@ -484,30 +719,65 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest)
// 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
}
@@ -523,7 +793,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
}
@@ -539,7 +816,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
}
@@ -555,7 +839,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
}
@@ -571,9 +862,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
}
@@ -589,9 +887,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
}
@@ -607,9 +912,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
}
@@ -625,18 +937,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
}
@@ -652,19 +978,40 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
// ToggleStarred toggles entry starred value.
func (c *Client) ToggleStarred(entryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
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
}
@@ -683,7 +1030,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
}
@@ -699,13 +1053,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
}
@@ -721,7 +1089,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
}
@@ -737,7 +1112,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
+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
}
}
+25 -23
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 any) (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 any) (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 any) (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 any) (io.ReadCloser, error)
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 any) (io.ReadCloser, error)
}
}
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 any) (io.ReadCloser, error)
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)
+11 -11
View File
@@ -5,17 +5,17 @@ module miniflux.app/v2
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.15.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/go-webauthn/webauthn v0.14.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.3
golang.org/x/crypto v0.42.0
golang.org/x/image v0.31.0
golang.org/x/net v0.44.0
golang.org/x/oauth2 v0.31.0
golang.org/x/term v0.35.0
github.com/tdewolff/minify/v2 v2.24.4
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
golang.org/x/term v0.36.0
)
require (
@@ -29,7 +29,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -37,11 +37,11 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tdewolff/parse/v2 v2.8.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
+22 -22
View File
@@ -8,15 +8,15 @@ 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.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
@@ -60,10 +60,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.4 h1:pQyr6eWDa+RXtAoZg+6wurh0jB9ojqw/qc5LlU7/z6c=
github.com/tdewolff/minify/v2 v2.24.4/go.mod h1:iD9Qn7/brhKY9d0KLKMkZrqS8/bqxSxRKruBi7V6m+w=
github.com/tdewolff/parse/v2 v2.8.4 h1:A6slgBLGGDPBMGA28KQZfHpaKffuNvhOe7zSag+x/rw=
github.com/tdewolff/parse/v2 v2.8.4/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/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=
@@ -83,10 +83,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -101,10 +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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -123,8 +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.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=
@@ -134,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.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -145,8 +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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+2 -1
View File
@@ -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{
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"bufio"
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"net/url"
@@ -60,7 +61,7 @@ func (cp *configParser) postParsing() error {
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return fmt.Errorf("BASE_URL scheme must be http or https")
return errors.New("BASE_URL scheme must be http or https")
}
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
@@ -294,7 +295,7 @@ func readSecretFileValue(filename string) (string, error) {
value := string(bytes.TrimSpace(data))
if value == "" {
return "", fmt.Errorf("secret file is empty")
return "", errors.New("secret file is empty")
}
return value, nil
+4 -3
View File
@@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"errors"
"fmt"
"slices"
"strconv"
@@ -29,7 +30,7 @@ func validateListChoices(inputValues, choices []string) error {
func validateGreaterThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
return errors.New("value must be an integer")
}
if intValue > min {
return nil
@@ -40,7 +41,7 @@ func validateGreaterThan(rawValue string, min int) error {
func validateGreaterOrEqualThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
return errors.New("value must be an integer")
}
if intValue >= min {
return nil
@@ -51,7 +52,7 @@ func validateGreaterOrEqualThan(rawValue string, min int) error {
func validateRange(rawValue string, min, max int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
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)
+48 -16
View File
@@ -20,7 +20,7 @@ var migrations = [...]func(tx *sql.Tx) error{
);
CREATE TABLE users (
id serial not null,
id SERIAL,
username text not null unique,
password text,
is_admin bool default 'f',
@@ -32,7 +32,7 @@ var migrations = [...]func(tx *sql.Tx) error{
);
CREATE TABLE sessions (
id serial not null,
id SERIAL,
user_id int not null,
token text not null unique,
created_at timestamp with time zone default now(),
@@ -44,7 +44,7 @@ var migrations = [...]func(tx *sql.Tx) error{
);
CREATE TABLE categories (
id serial not null,
id SERIAL,
user_id int not null,
title text not null,
primary key (id),
@@ -53,7 +53,7 @@ var migrations = [...]func(tx *sql.Tx) error{
);
CREATE TABLE feeds (
id bigserial not null,
id BIGSERIAL,
user_id int not null,
category_id int not null,
title text not null,
@@ -73,7 +73,7 @@ var migrations = [...]func(tx *sql.Tx) error{
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
CREATE TABLE entries (
id bigserial not null,
id BIGSERIAL,
user_id int not null,
feed_id bigint not null,
hash text not null,
@@ -92,7 +92,7 @@ var migrations = [...]func(tx *sql.Tx) error{
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
CREATE TABLE enclosures (
id bigserial not null,
id BIGSERIAL,
user_id int not null,
entry_id bigint not null,
url text not null,
@@ -104,7 +104,7 @@ var migrations = [...]func(tx *sql.Tx) error{
);
CREATE TABLE icons (
id bigserial not null,
id BIGSERIAL,
hash text not null unique,
mime_type text not null,
content bytea not null,
@@ -123,13 +123,9 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN extra hstore;
CREATE INDEX users_extra_idx ON users using gin(extra);
`
_, err = tx.Exec(sql)
return err
// This used to create a HSTORE `extra` column in the table `users`,
// which hasn't been used since Miniflux 2.0.27.
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
@@ -334,7 +330,7 @@ var migrations = [...]func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE api_keys (
id serial not null,
id SERIAL,
user_id int not null references users(id) on delete cascade,
token text not null unique,
description text not null,
@@ -436,6 +432,18 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
hasExtra := false
if err := tx.QueryRow(`
SELECT true
FROM information_schema.columns
WHERE
table_name='users' AND
column_name='extra';
`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {
return err
}
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
@@ -446,6 +454,11 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
}
if !hasExtra {
// No need to migrate things from the `extra` column if it's not present
return nil
}
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
@@ -495,7 +508,7 @@ var migrations = [...]func(tx *sql.Tx) error{
return err
},
func(tx *sql.Tx) (err error) {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {
return err
}
_, err = tx.Exec(`
@@ -1341,4 +1354,23 @@ var migrations = [...]func(tx *sql.Tx) error{
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `DROP EXTENSION IF EXISTS hstore;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
}
+6 -6
View File
@@ -143,7 +143,7 @@ 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 {
subscription := feed{
ID: f.ID,
@@ -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))
}
@@ -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,
+4 -5
View File
@@ -126,8 +126,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
}
@@ -280,7 +279,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
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
}
@@ -1014,7 +1013,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
userInfo := userInfoResponse{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
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)
}
@@ -1048,7 +1047,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 {
+2 -1
View File
@@ -4,6 +4,7 @@
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -58,7 +59,7 @@ func parseItemID(itemIDValue string) (int64, error) {
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[paramItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
return nil, errors.New("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
+5 -5
View File
@@ -29,19 +29,19 @@ func (r RequestModifiers) String() string {
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
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, ", ")))
var exclusions []string
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, ", ")))
var filters []string
filters := make([]string, 0, len(r.FilterTargets))
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
@@ -49,8 +49,8 @@ func (r RequestModifiers) String() string {
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, "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))
+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{}
+18 -2
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"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"
@@ -398,6 +399,16 @@ 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 != "" {
@@ -446,14 +457,20 @@ 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)
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),
@@ -506,7 +523,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
)
}
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if feed.WebhookURL != "" {
+91 -9
View File
@@ -6,9 +6,11 @@ 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"
@@ -16,9 +18,15 @@ import (
const defaultClientTimeout = 10 * time.Second
type errorResponse struct {
Code string `json:"code"`
Error string `json:"error"`
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
tags string
}
type tagItem struct {
TagName string `json:"tagName"`
}
type saveURLPayload struct {
@@ -26,14 +34,75 @@ type saveURLPayload struct {
URL string `json:"url"`
}
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
type saveURLResponse struct {
ID string `json:"id"`
}
func NewClient(apiToken string, apiEndpoint string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
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 {
@@ -77,5 +146,18 @@ func (c *Client) SaveURL(entryURL string) error {
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 {
+2 -1
View File
@@ -6,6 +6,7 @@ package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -44,7 +45,7 @@ func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
if c.apiToken == "" || c.orgSlug == "" {
return fmt.Errorf("linktaco: missing API token or organization slug")
return errors.New("linktaco: missing API token or organization slug")
}
description := entryContent
@@ -6,6 +6,7 @@ package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -27,7 +28,7 @@ func NewClient(baseURL, apiKey string) *Client {
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")
+2 -2
View File
@@ -23,8 +23,8 @@ func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixU
return err
}
var textMessages []string
var formattedTextMessages []string
textMessages := make([]string, 0, len(entries))
formattedTextMessages := make([]string, 0, len(entries))
for _, entry := range entries {
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
+2 -1
View File
@@ -6,6 +6,7 @@ package notion
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -26,7 +27,7 @@ func NewClient(apiToken, pageID string) *Client {
func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
if c.apiToken == "" || c.pageID == "" {
return fmt.Errorf("notion: missing API token or page ID")
return errors.New("notion: missing API token or page ID")
}
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
@@ -6,6 +6,7 @@ package nunuxkeeper // import "miniflux.app/v2/internal/integration/nunuxkeeper"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -27,7 +28,7 @@ func NewClient(baseURL, apiKey string) *Client {
func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("nunux-keeper: missing base URL or API key")
return errors.New("nunux-keeper: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"miniflux.app/v2/internal/version"
)
var errPostNotFound = fmt.Errorf("pinboard: post not found")
var errMissingCredentials = fmt.Errorf("pinboard: missing auth token")
var errPostNotFound = errors.New("pinboard: post not found")
var errMissingCredentials = errors.New("pinboard: missing auth token")
const defaultClientTimeout = 10 * time.Second
+2 -1
View File
@@ -5,6 +5,7 @@ package pushover // import "miniflux.app/v2/internal/integration/pushover"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -72,7 +73,7 @@ func New(user, token string, priority int, device, urlPrefix string) *Client {
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
if c.token == "" || c.user == "" {
return fmt.Errorf("pushover token and user are required")
return errors.New("pushover token and user are required")
}
for _, entry := range entries {
msg := &Message{
+2 -1
View File
@@ -6,6 +6,7 @@ package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -29,7 +30,7 @@ func NewClient(token, collectionID, tags string) *Client {
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
if c.token == "" {
return fmt.Errorf("raindrop: missing token")
return errors.New("raindrop: missing token")
}
var request *http.Request
+2 -1
View File
@@ -6,6 +6,7 @@ package readeck // import "miniflux.app/v2/internal/integration/readeck"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
@@ -31,7 +32,7 @@ func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("readeck: missing base URL or API key")
return errors.New("readeck: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
+2 -1
View File
@@ -8,6 +8,7 @@ package readwise // import "miniflux.app/v2/internal/integration/readwise"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -30,7 +31,7 @@ func NewClient(apiKey string) *Client {
func (c *Client) CreateDocument(entryURL string) error {
if c.apiKey == "" {
return fmt.Errorf("readwise: missing API key")
return errors.New("readwise: missing API key")
}
requestBody, err := json.Marshal(&readwiseDocument{
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -30,7 +31,7 @@ func NewClient(baseURL, apiSecret string) *Client {
func (c *Client) CreateLink(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiSecret == "" {
return fmt.Errorf("shaarli: missing base URL or API secret")
return errors.New("shaarli: missing base URL or API secret")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
+2 -1
View File
@@ -6,6 +6,7 @@ package shiori // import "miniflux.app/v2/internal/integration/shiori"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -28,7 +29,7 @@ func NewClient(baseURL, username, password string) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.username == "" || c.password == "" {
return fmt.Errorf("shiori: missing base URL, username or password")
return errors.New("shiori: missing base URL, username or password")
}
token, err := c.authenticate()
+2 -1
View File
@@ -6,6 +6,7 @@ package wallabag // import "miniflux.app/v2/internal/integration/wallabag"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -34,7 +35,7 @@ func NewClient(baseURL, clientID, clientSecret, username, password, tags string,
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("wallabag: missing base URL, client ID, client secret, username or password")
return errors.New("wallabag: missing base URL, client ID, client secret, username or password")
}
accessToken, err := c.getAccessToken()
+3 -2
View File
@@ -6,6 +6,7 @@ package webhook // import "miniflux.app/v2/internal/integration/webhook"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
@@ -72,7 +73,7 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
return nil
}
var webhookEntries []*WebhookEntry
webhookEntries := make([]*WebhookEntry, 0, len(entries))
for _, entry := range entries {
webhookEntries = append(webhookEntries, &WebhookEntry{
ID: entry.ID,
@@ -113,7 +114,7 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
func (c *Client) makeRequest(eventType string, payload any) error {
if c.webhookURL == "" {
return fmt.Errorf(`webhook: missing webhook URL`)
return errors.New(`webhook: missing webhook URL`)
}
requestBody, err := json.Marshal(payload)
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Webhook-URL überschreiben",
"form.import.label.file": "OPML-Datei",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Artikel zu archive.org pushen",
"form.integration.apprise_activate": "Artikel zu Apprise pushen",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
"form.integration.apprise_url": "Apprise-API-URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Artikel in Karakeep speichern",
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
"form.integration.karakeep_tags": "Karakeep-Tags",
"form.integration.linkace_activate": "Artikel in LinkAce speichern",
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Παράκαμψη διεύθυνσης URL webhook",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "Διεύθυνση URL",
"form.integration.archiveorg_activate": "Προώθηση καταχωρήσεων στο archive.org",
"form.integration.apprise_activate": "Προώθηση καταχωρήσεων στο Apprise",
"form.integration.apprise_services_url": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
"form.integration.apprise_url": "Διεύθυνση URL API Apprise",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Αποθήκευση άρθρων στο Karakeep",
"form.integration.karakeep_api_key": "Κλειδί API Karakeep",
"form.integration.karakeep_url": "Τελικό σημείο Karakeep API",
"form.integration.karakeep_tags": "Ετικέτες Karakeep",
"form.integration.linkace_activate": "Αποθήκευση καταχωρήσεων στο LinkAce",
"form.integration.linkace_api_key": "Κλειδί API LinkAce",
"form.integration.linkace_check_disabled": "Απενεργοποίηση ελέγχου συνδέσμου",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "OPML file",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Push entries to archive.org",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Invalidar la URL del webhook",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
"form.integration.apprise_activate": "Enviar artículos a Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
"form.integration.apprise_url": "URL de la API de Apprise",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Enviar artículos a Karakeep",
"form.integration.karakeep_api_key": "Clave de API de Karakeep",
"form.integration.karakeep_url": "Acceso API de Karakeep",
"form.integration.karakeep_tags": "Etiquetas de Karakeep",
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
"form.integration.linkace_api_key": "Clave API de LinkAce",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Työnnä merkinnät osoitteeseen archive.org",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
"form.integration.karakeep_api_key": "Karakeep API-avain",
"form.integration.karakeep_url": "Karakeep API-päätepiste",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Remplacer l'URL du webhook",
"form.import.label.file": "Fichier OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Envoyer les articles vers archive.org",
"form.integration.apprise_activate": "Envoyer les articles vers Apprise",
"form.integration.apprise_services_url": "Liste des services Apprise séparés par des virgules",
"form.integration.apprise_url": "URL de l'API Apprise",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Sauvegarder les articles vers Karakeep",
"form.integration.karakeep_api_key": "Clé d'API de Karakeep",
"form.integration.karakeep_url": "URL de l'API de Karakeep",
"form.integration.karakeep_tags": "Libellés Karakeep",
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
"form.integration.linkace_api_key": "Clé d'API LinkAce",
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.archiveorg_activate": "प्रविष्टियों को archive.org पर भेजें",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.karakeep_tags": "Karakeep Labels",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
+2
View File
@@ -209,6 +209,7 @@
"form.feed.label.webhook_url": "Timpa URL Webhook",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Push entries to archive.org",
"form.integration.apprise_activate": "Kirim artikel ke Apprise",
"form.integration.apprise_services_url": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
"form.integration.apprise_url": "URL API Apprise",
@@ -237,6 +238,7 @@
"form.integration.karakeep_activate": "Simpan artikel ke Karakeep",
"form.integration.karakeep_api_key": "Kunci API Karakeep",
"form.integration.karakeep_url": "Titik URL API Karakeep",
"form.integration.karakeep_tags": "Tanda di Karakeep",
"form.integration.linkace_activate": "Simpan artikel ke LinkAce",
"form.integration.linkace_api_key": "Kunci API LinkAce",
"form.integration.linkace_check_disabled": "Matikan pemeriksaan tautan",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "File OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Invia le voci ad archive.org",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Salva gli articoli su Karakeep",
"form.integration.karakeep_api_key": "API key dell'account Karakeep",
"form.integration.karakeep_url": "Endpoint dell'API di Karakeep",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Salva gli articoli su LinkAce",
"form.integration.linkace_api_key": "API key dell'account LinkAce",
"form.integration.linkace_check_disabled": "Disabilita i controlli",
+2
View File
@@ -209,6 +209,7 @@
"form.feed.label.webhook_url": "Override webhook url",
"form.import.label.file": "OPML ファイル",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "エントリーをarchive.orgにプッシュする",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
@@ -237,6 +238,7 @@
"form.integration.karakeep_activate": "Karakeep に記事を保存する",
"form.integration.karakeep_api_key": "Karakeep の API key",
"form.integration.karakeep_url": "Karakeep の API Endpoint",
"form.integration.karakeep_tags": "Karakeep の Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -209,6 +209,7 @@
"form.feed.label.webhook_url": "Ngī kái webhook bāng-chí",
"form.import.label.file": "OPML tóng-àn",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Push entries to archive.org",
"form.integration.apprise_activate": "Thui sàng siau-sit khì Apprise",
"form.integration.apprise_services_url": "Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.integration.apprise_url": "Apprise API bāng-chí",
@@ -237,6 +238,7 @@
"form.integration.karakeep_activate": "Pó-chûn siau-sit kàu Karakeep",
"form.integration.karakeep_api_key": "Karakeep API só-sî",
"form.integration.karakeep_url": "Karakeep API thâu",
"form.integration.karakeep_tags": "Karakeep khan-á",
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
"form.integration.linkace_api_key": "LinkAce API só-sî",
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Overschrijf webhook URL",
"form.import.label.file": "OPML-bestand",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Push entries to archive.org",
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Artikelen opslaan in Karakeep",
"form.integration.karakeep_api_key": "Karakeep API-sleutel",
"form.integration.karakeep_url": "Karakeep URL",
"form.integration.karakeep_tags": "Karakeep tags",
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
"form.integration.linkace_api_key": "LinkAce API-sleutel",
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
+2
View File
@@ -215,6 +215,7 @@
"form.feed.label.webhook_url": "Zastąp adres URL webhooka",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "Adres URL",
"form.integration.archiveorg_activate": "Prześlij wpisy do archive.org",
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
"form.integration.apprise_url": "Adres URL API Apprise",
@@ -243,6 +244,7 @@
"form.integration.karakeep_activate": "Zapisuj wpisy w Karakeep",
"form.integration.karakeep_api_key": "Klucz API do Karakeep",
"form.integration.karakeep_url": "Punkt końcowy API Karakeep",
"form.integration.karakeep_tags": "Znaczniki Karakeep",
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
"form.integration.linkace_api_key": "Klucz API do LinkAce",
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Sobrescrever URL do webhook",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Enviar itens para o archive.org",
"form.integration.apprise_activate": "Enviar itens para o Apprise",
"form.integration.apprise_services_url": "Lista de URLs de serviços Apprise separadas por vírgula",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Salvar itens no Karakeep",
"form.integration.karakeep_api_key": "Chave de API do Karakeep",
"form.integration.karakeep_url": "Endpoint de API do Karakeep",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Salvar itens no LinkAce",
"form.integration.linkace_api_key": "Chave de API do LinkAce",
"form.integration.linkace_check_disabled": "Desativar verificação de link",
+2
View File
@@ -215,6 +215,7 @@
"form.feed.label.webhook_url": "URL Webhook (pentru a primi notificări despre evenimentele de intrare)",
"form.import.label.file": "Fișier OPML",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Trimite înregistrările pe archive.org",
"form.integration.apprise_activate": "Trimite înregistrările pe Apprise",
"form.integration.apprise_services_url": "URL-uri separate de virgulă cu servicii Apprise",
"form.integration.apprise_url": "URL API Apprise",
@@ -243,6 +244,7 @@
"form.integration.karakeep_activate": "Salvare înregistrări în Karakeep",
"form.integration.karakeep_api_key": "Cheie API Karakeep",
"form.integration.karakeep_url": "Punct acces API Karakeep",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Salvează intrările în LinkAce",
"form.integration.linkace_api_key": "Cheie API LinkAce",
"form.integration.linkace_check_disabled": "Dezactivează verificarea link-urilor",
+2
View File
@@ -215,6 +215,7 @@
"form.feed.label.webhook_url": "Переопределить URL вебхука",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.archiveorg_activate": "TОтправить статьи в archive.org",
"form.integration.apprise_activate": "Отправить статьи в Apprise",
"form.integration.apprise_services_url": "Список ссылок сервисов Apprise, разделенный запятой",
"form.integration.apprise_url": "Ссылка на Apprise API",
@@ -243,6 +244,7 @@
"form.integration.karakeep_activate": "Сохранять статьи в Karakeep",
"form.integration.karakeep_api_key": "API-ключ Karakeep",
"form.integration.karakeep_url": "Конечная точка Karakeep API",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Сохранять статьи в LinkAce",
"form.integration.linkace_api_key": "API-ключ LinkAce",
"form.integration.linkace_check_disabled": "Отключить проверку ссылок",
+2
View File
@@ -212,6 +212,7 @@
"form.feed.label.webhook_url": "Webhook URL'sini geçersiz kıl",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "Makaleleri archive.org'a gönder",
"form.integration.apprise_activate": "Makaleleri Apprise'a gönder",
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
"form.integration.apprise_url": "Apprise API URL",
@@ -240,6 +241,7 @@
"form.integration.karakeep_activate": "Makaleleri Karakeep'a kaydet",
"form.integration.karakeep_api_key": "Karakeep API anahtarı",
"form.integration.karakeep_url": "Karakeep API Uç Noktası",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Makaleleri LinkAce'e kaydet",
"form.integration.linkace_api_key": "LinkAce API anahtarı",
"form.integration.linkace_check_disabled": "Link kontrolünü devre dışı bırak",
+2
View File
@@ -215,6 +215,7 @@
"form.feed.label.webhook_url": "Перевизначити URL вебхука",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.archiveorg_activate": "Надсилати записи у archive.org",
"form.integration.apprise_activate": "Надсилати записи у Apprise",
"form.integration.apprise_services_url": "Список URL сервісів Apprise, розділених комами",
"form.integration.apprise_url": "Apprise API URL",
@@ -243,6 +244,7 @@
"form.integration.karakeep_activate": "Зберігати статті до Karakeep",
"form.integration.karakeep_api_key": "Ключ API Karakeep",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.karakeep_tags": "Karakeep Tags",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
+2
View File
@@ -209,6 +209,7 @@
"form.feed.label.webhook_url": "覆盖 Webhook URL",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "将新条目推送到 archive.org",
"form.integration.apprise_activate": "将新条目推送到 Apprise",
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.integration.apprise_url": "Apprise API URL",
@@ -237,6 +238,7 @@
"form.integration.karakeep_activate": "保存条目到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 密钥",
"form.integration.karakeep_url": "Karakeep API 端点",
"form.integration.karakeep_tags": "Karakeep 标签",
"form.integration.linkace_activate": "保存条目到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 密钥",
"form.integration.linkace_check_disabled": "禁用链接检查",
+2
View File
@@ -209,6 +209,7 @@
"form.feed.label.webhook_url": "覆蓋webhook URL",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.archiveorg_activate": "推送文章到 archive.org",
"form.integration.apprise_activate": "推送文章到 Apprise",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址列表",
"form.integration.apprise_url": "Apprise API 網址",
@@ -237,6 +238,7 @@
"form.integration.karakeep_activate": "儲存文章到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 金鑰",
"form.integration.karakeep_url": "Karakeep API 端點",
"form.integration.karakeep_tags": "Karakeep 標籤",
"form.integration.linkace_activate": "儲存文章到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 金鑰",
"form.integration.linkace_check_disabled": "停用連結檢查",
+2
View File
@@ -100,6 +100,7 @@ type Integration struct {
KarakeepEnabled bool
KarakeepAPIKey string
KarakeepURL string
KarakeepTags string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
@@ -123,4 +124,5 @@ type Integration struct {
PushoverToken string
PushoverDevice string
PushoverPrefix string
ArchiveorgEnabled bool
}
+5 -6
View File
@@ -228,14 +228,14 @@ var dateFormats = [...]string{
"02.01.06",
}
var invalidTimezoneReplacer = strings.NewReplacer(
var replacer = strings.NewReplacer(
// Timezones
"Europe/Brussels", "CET",
"America/Los_Angeles", "PDT",
"GMT+0000 (Coordinated Universal Time)", "GMT",
"GMT-", "GMT -",
)
var invalidLocalizedDateReplacer = strings.NewReplacer(
// Localized dates
"Mo,", "Mon,",
"Di,", "Tue,",
"Mi,", "Wed,",
@@ -325,8 +325,7 @@ func Parse(rawInput string) (t time.Time, err error) {
return time.Unix(timestamp, 0), nil
}
processedInput := invalidLocalizedDateReplacer.Replace(rawInput)
processedInput = invalidTimezoneReplacer.Replace(processedInput)
processedInput := replacer.Replace(rawInput)
for _, layout := range dateFormatsLocalTimesOnly {
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
@@ -366,7 +365,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Avoid "pq: time zone displacement out of range" errors
func checkTimezoneRange(t time.Time) time.Time {
_, offset := t.Zone()
if float64(offset) > 14*60*60 || float64(offset) < -12*60*60 {
if offset > 14*60*60 || offset < -12*60*60 {
t = t.UTC()
}
return t
+4 -1
View File
@@ -1,4 +1,7 @@
package fetcher
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
import (
"compress/gzip"
+1 -1
View File
@@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Package filter provides functions to filter entries based on user-defined rules.
//
// There are two types of rules:
+1 -1
View File
@@ -23,7 +23,7 @@ type ItunesChannelElement struct {
}
func (i *ItunesChannelElement) GetItunesCategories() []string {
var categories []string
categories := make([]string, 0, len(i.ItunesCategories))
for _, category := range i.ItunesCategories {
categories = append(categories, category.Text)
if category.SubCategory != nil {
+9 -4
View File
@@ -68,11 +68,16 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
for _, item := range j.jsonFeed.Items {
entry := model.NewEntry()
entry.Title = strings.TrimSpace(item.Title)
entry.URL = strings.TrimSpace(item.URL)
// Make sure the entry URL is absolute.
if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = entryURL
for _, itemURL := range []string{item.URL, item.ExternalURL} {
itemURL = strings.TrimSpace(itemURL)
if itemURL != "" {
// Make sure the entry URL is absolute.
if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, itemURL); err == nil {
entry.URL = entryURL
}
break
}
}
// The entry title is optional, so we need to find a fallback.
+57
View File
@@ -415,6 +415,63 @@ func TestParseItemWithRelativeURL(t *testing.T) {
}
}
func TestParseItemWithExternalURLAndNoURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "Example",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "1234259",
"external_url": "some_page.html"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].URL != "https://example.org/some_page.html" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
}
func TestParseItemWithExternalURLAndURL(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
"title": "Example",
"home_page_url": "https://example.org/",
"feed_url": "https://example.org/feed.json",
"items": [
{
"id": "1234259",
"url": "https://example.org/article",
"external_url": "https://example.org/another-article"
}
]
}`
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].URL != "https://example.org/article" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
}
func TestParseItemWithLegacyAuthorField(t *testing.T) {
data := `{
"version": "https://jsonfeed.org/version/1",
+3 -2
View File
@@ -5,6 +5,7 @@ package processor // import "miniflux.app/v2/internal/reader/processor"
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
@@ -75,12 +76,12 @@ func fetchBilibiliWatchTime(websiteURL string) (int, error) {
data, ok := result["data"].(map[string]any)
if !ok {
return 0, fmt.Errorf("data field not found or not an object")
return 0, errors.New("data field not found or not an object")
}
duration, ok := data["duration"].(float64)
if !ok {
return 0, fmt.Errorf("duration not found or not a number")
return 0, errors.New("duration not found or not a number")
}
intDuration := int(duration)
durationMin := intDuration / 60
+2 -2
View File
@@ -49,11 +49,11 @@ func fetchWatchTime(websiteURL, query string, isoDate bool) (int, error) {
}
ret = int(parsedDuration.Minutes())
} else {
parsedDuration, err := strconv.ParseInt(duration, 10, 64)
parsedDuration, err := strconv.Atoi(duration)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", duration, err)
}
ret = int(parsedDuration / 60)
ret = parsedDuration / 60
}
return ret, nil
}
+5 -5
View File
@@ -25,31 +25,31 @@ func parseISO8601Duration(duration string) (time.Duration, error) {
num := ""
for _, char := range after {
var val float64
var val int
var err error
switch char {
case 'Y', 'W', 'D':
return 0, fmt.Errorf("the '%c' specifier isn't supported", char)
case 'H':
if val, err = strconv.ParseFloat(num, 64); err != nil {
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Hour
num = ""
case 'M':
if val, err = strconv.ParseFloat(num, 64); err != nil {
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Minute
num = ""
case 'S':
if val, err = strconv.ParseFloat(num, 64); err != nil {
if val, err = strconv.Atoi(num); err != nil {
return 0, err
}
d += time.Duration(val) * time.Second
num = ""
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.':
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
num += string(char)
continue
default:
+4 -4
View File
@@ -70,10 +70,10 @@ func TestISO8601DurationParsingErrors(t *testing.T) {
{"PT1a2H", "invalid character in the period"},
{"PT3b4M", "invalid character in the period"},
{"PT5c6S", "invalid character in the period"},
// Test cases for actual ParseFloat errors (empty number before specifier)
{"PTH", "strconv.ParseFloat: parsing \"\": invalid syntax"},
{"PTM", "strconv.ParseFloat: parsing \"\": invalid syntax"},
{"PTS", "strconv.ParseFloat: parsing \"\": invalid syntax"},
// Test cases for actual Atoi errors (empty number before specifier)
{"PTH", "strconv.Atoi: parsing \"\": invalid syntax"},
{"PTM", "strconv.Atoi: parsing \"\": invalid syntax"},
{"PTS", "strconv.Atoi: parsing \"\": invalid syntax"},
// Invalid character
{"PT1X", "invalid character in the period"},
// Invalid character mixed
+2 -2
View File
@@ -43,8 +43,8 @@ func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
}
func fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {
var videosEntriesMapping = make(map[string]*model.Entry, len(entries))
var videoIDs []string
videosEntriesMapping := make(map[string]*model.Entry, len(entries))
videoIDs := make([]string, 0, len(entries))
for _, entry := range entries {
if !isYouTubeVideoURL(entry.URL) {
+1 -2
View File
@@ -62,11 +62,10 @@ func (c *candidate) String() string {
type candidateList map[*html.Node]*candidate
func (c candidateList) String() string {
var output []string
output := make([]string, 0, len(c))
for _, candidate := range c {
output = append(output, candidate.String())
}
return strings.Join(output, ", ")
}
@@ -5,6 +5,7 @@ package readability // import "miniflux.app/v2/internal/reader/readability"
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
@@ -2301,5 +2302,5 @@ func TestExtractContentWithBrokenReader(t *testing.T) {
type brokenReader struct{}
func (br *brokenReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("simulated read error")
return 0, errors.New("simulated read error")
}
+3 -1
View File
@@ -94,6 +94,8 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
entry.Title = titlelize(entry.Title)
case "fix_ghost_cards":
entry.Content = fixGhostCards(entry.Content)
case "remove_img_blur_params":
entry.Content = removeImgBlurParams(entry.Content)
}
}
@@ -130,7 +132,7 @@ func parseRules(rulesText string) (rules []rule) {
rules[l].args = append(rules[l].args, text)
}
case scanner.EOF:
return
return rules
}
}
}
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
@@ -547,3 +548,43 @@ func fixGhostCards(entryContent string) string {
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
return strings.TrimSpace(output)
}
func removeImgBlurParams(entryContent string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
if err != nil {
return entryContent
}
changed := false
doc.Find("img[src]").Each(func(i int, img *goquery.Selection) {
srcAttr, exists := img.Attr("src")
if !exists {
return
}
parsedURL, err := url.Parse(srcAttr)
if err != nil {
return
}
// Only strip query parameters if this is a blurry placeholder image
if parsedURL.RawQuery != "" {
// Check if there's a blur parameter with a non-zero value
if blurValue := parsedURL.Query().Get("blur"); blurValue != "" {
if blurInt, err := strconv.Atoi(blurValue); err == nil && blurInt > 0 {
parsedURL.RawQuery = ""
img.SetAttr("src", parsedURL.String())
changed = true
}
}
}
})
if changed {
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
return output
}
return entryContent
}
@@ -14,6 +14,7 @@ var predefinedRules = map[string]string{
"cowbirdsinlove.com": "add_image_title",
"drawingboardcomic.com": "add_image_title",
"exocomics.com": "add_image_title",
"explainxkcd.com": "add_image_title",
"framatube.org": "nl2br,convert_text_link",
"happletea.com": "add_image_title",
"ilpost.it": `remove(".art_tag, #audioPlayerArticle, .author-container, .caption, .ilpostShare, .lastRecents, #mc_embed_signup, .outbrain_inread, p:has(.leggi-anche), .youtube-overlay")`,
+156 -2
View File
@@ -133,7 +133,6 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -241,7 +240,6 @@ func TestAddYoutubeVideoFromIdWithCustomEmbedURL(t *testing.T) {
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -797,6 +795,7 @@ func TestRewriteRemoveCustom(t *testing.T) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestRewriteRemoveQuotedSelector(t *testing.T) {
controlEntry := &model.Entry{
URL: "https://example.org/article",
@@ -1248,3 +1247,158 @@ func TestFixGhostCardMultipleSplit(t *testing.T) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestStripImageQueryParams(t *testing.T) {
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `News Article Title`,
Content: `
<article>
<p>Article content with images having query parameters:</p>
<img src="https://example.org/images/image1.jpg?width=200&height=113&q=80&blur=90" alt="Image with params">
<img src="https://example.org/images/image2.jpg?width=800&height=600&q=85" alt="Another image with params">
<p>More images with various query parameters:</p>
<img src="https://example.org/image123.jpg?blur=50&size=small&format=webp" alt="Complex query params">
<img src="https://example.org/image123.jpg?size=large&quality=95&cache=123" alt="Different params">
<p>Image without query parameters:</p>
<img src="https://example.org/single-image.jpg" alt="Clean image">
<p>Images with various other params:</p>
<img src="https://example.org/normal1.jpg?width=300&format=jpg" alt="Normal 1">
<img src="https://example.org/normal1.jpg?width=600&quality=high" alt="Normal 2">
</article>`,
}
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `News Article Title`,
Content: `<article>
<p>Article content with images having query parameters:</p>
<img src="https://example.org/images/image1.jpg" alt="Image with params"/>
<img src="https://example.org/images/image2.jpg?width=800&amp;height=600&amp;q=85" alt="Another image with params"/>
<p>More images with various query parameters:</p>
<img src="https://example.org/image123.jpg" alt="Complex query params"/>
<img src="https://example.org/image123.jpg?size=large&amp;quality=95&amp;cache=123" alt="Different params"/>
<p>Image without query parameters:</p>
<img src="https://example.org/single-image.jpg" alt="Clean image"/>
<p>Images with various other params:</p>
<img src="https://example.org/normal1.jpg?width=300&amp;format=jpg" alt="Normal 1"/>
<img src="https://example.org/normal1.jpg?width=600&amp;quality=high" alt="Normal 2"/>
</article>`,
}
ApplyContentRewriteRules(testEntry, `remove_img_blur_params`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestStripImageQueryParamsNoChanges(t *testing.T) {
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Article Without Images`,
Content: `<p>No images here:</p>
<div>Just some text content</div>
<a href="https://example.org">A link</a>`,
}
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Article Without Images`,
Content: `<p>No images here:</p>
<div>Just some text content</div>
<a href="https://example.org">A link</a>`,
}
ApplyContentRewriteRules(testEntry, `remove_img_blur_params`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestStripImageQueryParamsEdgeCases(t *testing.T) {
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Edge Cases`,
Content: `
<p>Edge cases for image query parameter stripping:</p>
<!-- Various query parameters -->
<img src="https://example.org/image1.jpg?blur=80&width=300" alt="Multiple params">
<!-- Complex query parameters -->
<img src="https://example.org/image2.jpg?BLUR=60&format=webp&cache=123" alt="Complex params">
<img src="https://example.org/image3.jpg?quality=high&version=2" alt="Other params">
<!-- Query params in middle of string -->
<img src="https://example.org/image4.jpg?size=large&blur=30&format=webp&quality=90" alt="Middle params">
<!-- Image without query params -->
<img src="https://example.org/clean.jpg" alt="Clean image">
`,
}
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Edge Cases`,
Content: `<p>Edge cases for image query parameter stripping:</p>
<!-- Various query parameters -->
<img src="https://example.org/image1.jpg" alt="Multiple params"/>
<!-- Complex query parameters -->
<img src="https://example.org/image2.jpg?BLUR=60&amp;format=webp&amp;cache=123" alt="Complex params"/>
<img src="https://example.org/image3.jpg?quality=high&amp;version=2" alt="Other params"/>
<!-- Query params in middle of string -->
<img src="https://example.org/image4.jpg" alt="Middle params"/>
<!-- Image without query params -->
<img src="https://example.org/clean.jpg" alt="Clean image"/>
`,
}
ApplyContentRewriteRules(testEntry, `remove_img_blur_params`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
func TestStripImageQueryParamsSimple(t *testing.T) {
testEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Simple Test`,
Content: `
<p>Testing query parameter stripping:</p>
<!-- Images with various query parameters -->
<img src="https://example.org/test1.jpg?blur=0&width=300" alt="With blur zero">
<img src="https://example.org/test2.jpg?blur=50&width=300&format=webp" alt="With blur fifty">
<img src="https://example.org/test3.jpg?width=800&quality=high" alt="No blur param">
<img src="https://example.org/test4.jpg" alt="No params at all">
`,
}
controlEntry := &model.Entry{
URL: "https://example.org/article",
Title: `Simple Test`,
Content: `<p>Testing query parameter stripping:</p>
<!-- Images with various query parameters -->
<img src="https://example.org/test1.jpg?blur=0&amp;width=300" alt="With blur zero"/>
<img src="https://example.org/test2.jpg" alt="With blur fifty"/>
<img src="https://example.org/test3.jpg?width=800&amp;quality=high" alt="No blur param"/>
<img src="https://example.org/test4.jpg" alt="No params at all"/>
`,
}
ApplyContentRewriteRules(testEntry, `remove_img_blur_params`)
if !reflect.DeepEqual(testEntry, controlEntry) {
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
}
}
+63 -53
View File
@@ -138,46 +138,51 @@ var (
"linkedin.com/shareArticle",
}
validURISchemes = map[string]struct{}{
"apt": {},
"bitcoin": {},
"callto": {},
"dav": {},
"davs": {},
"ed2k": {},
"facetime": {},
"feed": {},
"ftp": {},
"geo": {},
"git": {},
"gopher": {},
"http": {},
"https": {},
"irc": {},
"irc6": {},
"ircs": {},
"itms-apps": {},
"itms": {},
"magnet": {},
"mailto": {},
"news": {},
"nntp": {},
"rtmp": {},
"sftp": {},
"sip": {},
"sips": {},
"skype": {},
"spotify": {},
"ssh": {},
"steam": {},
"svn": {},
"svn+ssh": {},
"tel": {},
"webcal": {},
"xmpp": {},
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
validURISchemes = []string{
// Most commong schemes on top.
"https:",
"http:",
// Then the rest.
"apt:",
"bitcoin:",
"callto:",
"dav:",
"davs:",
"ed2k:",
"facetime:",
"feed:",
"ftp:",
"geo:",
"git:",
"gopher:",
"irc:",
"irc6:",
"ircs:",
"itms-apps:",
"itms:",
"magnet:",
"mailto:",
"news:",
"nntp:",
"rtmp:",
"sftp:",
"sip:",
"sips:",
"skype:",
"spotify:",
"ssh:",
"steam:",
"svn:",
"svn+ssh:",
"tel:",
"webcal:",
"xmpp:",
// iOS Apps
"opener": {}, // https://www.opener.link
"hack": {}, // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
"opener:", // https://www.opener.link
"hack:", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
}
dataAttributeAllowedPrefixes = []string{
@@ -300,7 +305,8 @@ func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) s
}
func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) ([]string, string) {
var htmlAttrs, attrNames []string
htmlAttrs := make([]string, 0, len(attributes))
attrNames := make([]string, 0, len(attributes))
var err error
var isAnchorLink bool
@@ -461,29 +467,33 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
case "iframe":
return slices.Contains(attributes, "src")
case "source", "img":
return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
for _, attribute := range attributes {
if attribute == "src" || attribute == "srcset" {
return true
}
}
return false
default:
return true
}
}
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
func hasValidURIScheme(absoluteURL string) bool {
colonIndex := strings.IndexByte(absoluteURL, ':')
// Scheme must exist (colonIndex > 0). An empty scheme (e.g. ":foo") is not allowed.
if colonIndex <= 0 {
return false
for _, scheme := range validURISchemes {
if strings.HasPrefix(absoluteURL, scheme) {
return true
}
}
scheme := absoluteURL[:colonIndex]
_, ok := validURISchemes[strings.ToLower(scheme)]
return ok
return false
}
func isBlockedResource(absoluteURL string) bool {
return slices.ContainsFunc(blockedResourceURLSubstrings, func(element string) bool {
return strings.Contains(absoluteURL, element)
})
for _, blockedURL := range blockedResourceURLSubstrings {
if strings.Contains(absoluteURL, blockedURL) {
return true
}
}
return false
}
func isValidIframeSource(iframeSourceURL string) bool {
+3 -3
View File
@@ -4,7 +4,7 @@
package sanitizer
import (
"fmt"
"errors"
"strconv"
"strings"
)
@@ -54,11 +54,11 @@ func parseImageCandidate(input string) (*imageCandidate, error) {
return &imageCandidate{ImageURL: parts[0]}, nil
case 2:
if !isValidWidthOrDensityDescriptor(parts[1]) {
return nil, fmt.Errorf(`srcset: invalid descriptor`)
return nil, errors.New(`srcset: invalid descriptor`)
}
return &imageCandidate{ImageURL: parts[0], Descriptor: parts[1]}, nil
default:
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
return nil, errors.New(`srcset: invalid number of descriptors`)
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ var predefinedRules = map[string]string{
"bbc.co.uk": "div.vxp-column--single, div.story-body__inner, ul.gallery-images__list",
"blog.cloudflare.com": "div.post-content",
"cbc.ca": ".story-content",
"darkreading.com": "#article-main:not(header)",
"darkreading.com": "div.ArticleBase-Body",
"developpez.com": "div[itemprop=articleBody]",
"dilbert.com": "span.comic-title-name, img.img-comic",
"explosm.net": "div#comic",
+2 -2
View File
@@ -4,7 +4,7 @@
package urlcleaner // import "miniflux.app/v2/internal/reader/urlcleaner"
import (
"fmt"
"errors"
"net/url"
"strings"
)
@@ -97,7 +97,7 @@ var trackingParamsOutbound = map[string]bool{
func RemoveTrackingParameters(parsedFeedURL, parsedSiteURL, parsedInputUrl *url.URL) (string, error) {
if parsedFeedURL == nil || parsedSiteURL == nil || parsedInputUrl == nil {
return "", fmt.Errorf("urlcleaner: one of the URLs is nil")
return "", errors.New("urlcleaner: one of the URLs is nil")
}
queryParams := parsedInputUrl.Query()
+34 -23
View File
@@ -8,7 +8,6 @@ import (
"encoding/xml"
"fmt"
"io"
"strings"
"unicode/utf8"
"miniflux.app/v2/internal/reader/encoding"
@@ -22,35 +21,42 @@ func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
buffer := &bytes.Buffer{}
io.Copy(buffer, data)
enc := getEncoding(buffer.Bytes())
if enc == "" || strings.EqualFold(enc, "utf-8") {
// filter invalid chars now, since decoder.CharsetReader not called for utf-8 content
if hasUTF8XMLDeclaration(buffer.Bytes()) {
// TODO: detect actual encoding from bytes if not UTF-8 and convert to UTF-8 if needed.
// For now we just expect the invalid characters to be stripped out.
// Filter invalid chars now, since decoder.CharsetReader isn't called for utf-8 content
filteredBytes := filterValidXMLChars(buffer.Bytes())
decoder = xml.NewDecoder(bytes.NewReader(filteredBytes))
} else {
// filter invalid chars later within decoder.CharsetReader
data.Seek(0, io.SeekStart)
decoder = xml.NewDecoder(data)
// The XML document will be converted to UTF-8 by encoding.CharsetReader
// Invalid characters will be filtered later via decoder.CharsetReader
decoder.CharsetReader = charsetReaderFilterInvalidUtf8
}
decoder.Entity = xml.HTMLEntity
decoder.Strict = false
decoder.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
utf8Reader, err := encoding.CharsetReader(charset, input)
if err != nil {
return nil, err
}
rawData, err := io.ReadAll(utf8Reader)
if err != nil {
return nil, fmt.Errorf("encoding: unable to read data: %w", err)
}
filteredBytes := filterValidXMLChars(rawData)
return bytes.NewReader(filteredBytes), nil
}
return decoder
}
func charsetReaderFilterInvalidUtf8(charset string, input io.Reader) (io.Reader, error) {
utf8Reader, err := encoding.CharsetReader(charset, input)
if err != nil {
return nil, err
}
rawData, err := io.ReadAll(utf8Reader)
if err != nil {
return nil, fmt.Errorf("xml: unable to read data: %w", err)
}
filteredBytes := filterValidXMLChars(rawData)
return bytes.NewReader(filteredBytes), nil
}
// filterValidXMLChars filters inplace invalid XML characters.
// This function is inspired from bytes.Map
func filterValidXMLChars(s []byte) []byte {
@@ -89,23 +95,28 @@ func filterValidXMLChar(r rune) rune {
}
// This function is copied from encoding/xml's procInst and adapted for []bytes instead of string
func getEncoding(b []byte) string {
func getEncoding(b []byte) []byte {
// This parsing is somewhat lame and not exact.
// It works for all actual cases, though.
idx := bytes.Index(b, []byte("encoding="))
if idx == -1 {
return ""
return nil
}
v := b[idx+len("encoding="):]
if len(v) == 0 {
return ""
return nil
}
if v[0] != '\'' && v[0] != '"' {
return ""
return nil
}
idx = bytes.IndexRune(v[1:], rune(v[0]))
if idx == -1 {
return ""
return nil
}
return string(v[1 : idx+1])
return v[1 : idx+1]
}
func hasUTF8XMLDeclaration(data []byte) bool {
enc := getEncoding(data)
return enc == nil || bytes.EqualFold(enc, []byte("utf-8"))
}
+67
View File
@@ -6,11 +6,78 @@ package xml // import "miniflux.app/v2/internal/reader/xml"
import (
"encoding/xml"
"fmt"
"os"
"strings"
"testing"
"unicode/utf8"
)
func TestXMLDocumentWithISO88591Encoding(t *testing.T) {
fp, err := os.Open("testdata/iso88591.xml")
if err != nil {
t.Fatal(err)
}
defer fp.Close()
type myXMLDocument struct {
XMLName xml.Name `xml:"note"`
To string `xml:"to"`
From string `xml:"from"`
}
var doc myXMLDocument
decoder := NewXMLDecoder(fp)
err = decoder.Decode(&doc)
if err != nil {
t.Fatal(err)
}
expectedTo := "Anaïs"
expectedFrom := "Jürgen"
if doc.To != expectedTo {
t.Errorf(`Incorrect "to" field, expected: %q, got: %q`, expectedTo, doc.To)
}
if doc.From != expectedFrom {
t.Errorf(`Incorrect "from" field, expected: %q, got: %q`, expectedFrom, doc.From)
}
}
func TestXMLDocumentWithISO88591FileEncodingButUTF8Prolog(t *testing.T) {
fp, err := os.Open("testdata/iso88591_utf8_mismatch.xml")
if err != nil {
t.Fatal(err)
}
defer fp.Close()
type myXMLDocument struct {
XMLName xml.Name `xml:"note"`
To string `xml:"to"`
From string `xml:"from"`
}
var doc myXMLDocument
decoder := NewXMLDecoder(fp)
err = decoder.Decode(&doc)
if err != nil {
t.Fatal(err)
}
// TODO: detect actual encoding from bytes if not UTF-8 and convert to UTF-8 if needed.
// For now we just expect the invalid characters to be stripped out.
expectedTo := "Anas"
expectedFrom := "Jrgen"
if doc.To != expectedTo {
t.Errorf(`Incorrect "to" field, expected: %q, got: %q`, expectedTo, doc.To)
}
if doc.From != expectedFrom {
t.Errorf(`Incorrect "from" field, expected: %q, got: %q`, expectedFrom, doc.From)
}
}
func TestXMLDocumentWithIllegalUnicodeCharacters(t *testing.T) {
type myxml struct {
XMLName xml.Name `xml:"rss"`
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="iso8859-1"?>
<note>
<to>Anaïs</to>
<from>Jürgen</from>
</note>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<note>
<to>Anaïs</to>
<from>Jürgen</from>
</note>
+2 -1
View File
@@ -4,13 +4,14 @@
package storage // import "miniflux.app/v2/internal/storage"
import (
"errors"
"fmt"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
)
var ErrAPIKeyNotFound = fmt.Errorf("store: API Key not found")
var ErrAPIKeyNotFound = errors.New("store: API Key not found")
// APIKeyExists checks if an API Key with the same description exists.
func (s *Storage) APIKeyExists(userID int64, description string) bool {
+17 -8
View File
@@ -222,11 +222,13 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
karakeep_enabled,
karakeep_api_key,
karakeep_url,
karakeep_tags,
linktaco_enabled,
linktaco_api_token,
linktaco_org_slug,
linktaco_tags,
linktaco_visibility
linktaco_visibility,
archiveorg_enabled
FROM
integrations
WHERE
@@ -347,11 +349,13 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.KarakeepEnabled,
&integration.KarakeepAPIKey,
&integration.KarakeepURL,
&integration.KarakeepTags,
&integration.LinktacoEnabled,
&integration.LinktacoAPIToken,
&integration.LinktacoOrgSlug,
&integration.LinktacoTags,
&integration.LinktacoVisibility,
&integration.ArchiveorgEnabled,
)
switch {
case err == sql.ErrNoRows:
@@ -481,13 +485,15 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
karakeep_enabled=$110,
karakeep_api_key=$111,
karakeep_url=$112,
linktaco_enabled=$113,
linktaco_api_token=$114,
linktaco_org_slug=$115,
linktaco_tags=$116,
linktaco_visibility=$117
karakeep_tags=$113,
linktaco_enabled=$114,
linktaco_api_token=$115,
linktaco_org_slug=$116,
linktaco_tags=$117,
linktaco_visibility=$118,
archiveorg_enabled=$119
WHERE
user_id=$118
user_id=$120
`
_, err := s.db.Exec(
query,
@@ -603,11 +609,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.KarakeepEnabled,
integration.KarakeepAPIKey,
integration.KarakeepURL,
integration.KarakeepTags,
integration.LinktacoEnabled,
integration.LinktacoAPIToken,
integration.LinktacoOrgSlug,
integration.LinktacoTags,
integration.LinktacoVisibility,
integration.ArchiveorgEnabled,
integration.UserID,
)
@@ -651,7 +659,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
betula_enabled='t' OR
cubox_enabled='t' OR
discord_enabled='t' OR
slack_enabled='t'
slack_enabled='t' OR
archiveorg_enabled='t'
)
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
+3 -2
View File
@@ -6,6 +6,7 @@ package storage // import "miniflux.app/v2/internal/storage"
import (
"crypto/rand"
"database/sql"
"errors"
"fmt"
"time"
@@ -138,7 +139,7 @@ func (s *Storage) RemoveUserSessionByToken(userID int64, token string) error {
}
if count != 1 {
return fmt.Errorf(`store: nothing has been removed`)
return errors.New(`store: nothing has been removed`)
}
return nil
@@ -158,7 +159,7 @@ func (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error {
}
if count != 1 {
return fmt.Errorf(`store: nothing has been removed`)
return errors.New(`store: nothing has been removed`)
}
return nil
+2 -1
View File
@@ -4,6 +4,7 @@
package systemd // import "miniflux.app/v2/internal/systemd"
import (
"errors"
"fmt"
"net"
"os"
@@ -40,7 +41,7 @@ func WatchdogInterval() (time.Duration, error) {
}
if s <= 0 {
return 0, fmt.Errorf(`systemd: error WATCHDOG_USEC must be a positive number`)
return 0, errors.New(`systemd: error WATCHDOG_USEC must be a positive number`)
}
return time.Duration(s) * time.Microsecond, nil
+38 -2
View File
@@ -4,6 +4,7 @@
package template // import "miniflux.app/v2/internal/template"
import (
"errors"
"fmt"
"html/template"
"math"
@@ -33,6 +34,7 @@ type funcMap struct {
func (f *funcMap) Map() template.FuncMap {
return template.FuncMap{
"contains": strings.Contains,
"csp": csp,
"startsWith": strings.HasPrefix,
"formatFileSize": formatFileSize,
"dict": dict,
@@ -116,15 +118,49 @@ func (f *funcMap) Map() template.FuncMap {
}
}
func csp(user *model.User, nonce string) string {
policies := map[string]string{
"default-src": "'none'",
"frame-src": "*",
"img-src": "* data:",
"manifest-src": "'self'",
"media-src": "*",
"require-trusted-types-for": "'script'",
"script-src": "'nonce-" + nonce + "' 'strict-dynamic'",
"style-src": "'nonce-" + nonce + "'",
"trusted-types": "html url",
"connect-src": "'self'",
}
if user != nil {
if user.ExternalFontHosts != "" {
policies["font-src"] = user.ExternalFontHosts
if user.Stylesheet != "" {
policies["style-src"] += " " + user.ExternalFontHosts
}
}
}
var policy strings.Builder
for key, value := range policies {
policy.WriteString(key)
policy.WriteString(" ")
policy.WriteString(value)
policy.WriteString("; ")
}
return `<meta http-equiv="Content-Security-Policy" content="` + policy.String() + `">`
}
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict expects an even number of arguments")
return nil, errors.New("dict expects an even number of arguments")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
+91
View File
@@ -4,10 +4,12 @@
package template // import "miniflux.app/v2/internal/template"
import (
"strings"
"testing"
"time"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/model"
)
func TestDict(t *testing.T) {
@@ -159,3 +161,92 @@ func TestFormatFileSize(t *testing.T) {
}
}
}
func TestCSPExternalFont(t *testing.T) {
want := []string{
`default-src 'none';`,
`img-src * data:;`,
`media-src *;`,
`frame-src *;`,
`style-src 'nonce-1234';`,
`script-src 'nonce-1234'`,
`'strict-dynamic';`,
`font-src test.com;`,
`require-trusted-types-for 'script';`,
`trusted-types html url;`,
`manifest-src 'self';`,
}
got := csp(&model.User{ExternalFontHosts: "test.com"}, "1234")
for _, value := range want {
if !strings.Contains(got, value) {
t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
}
}
}
func TestCSPNoUser(t *testing.T) {
want := []string{
`default-src 'none';`,
`img-src * data:;`,
`media-src *;`,
`frame-src *;`,
`style-src 'nonce-1234';`,
`script-src 'nonce-1234'`,
`'strict-dynamic';`,
`require-trusted-types-for 'script';`,
`trusted-types html url;`,
`manifest-src 'self';`,
}
got := csp(nil, "1234")
for _, value := range want {
if !strings.Contains(got, value) {
t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
}
}
}
func TestCSPCustomJSExternalFont(t *testing.T) {
want := []string{
`default-src 'none';`,
`img-src * data:;`,
`media-src *;`,
`frame-src *;`,
`style-src 'nonce-1234';`,
`script-src 'nonce-1234'`,
`'strict-dynamic';`,
`require-trusted-types-for 'script';`,
`trusted-types html url;`,
`manifest-src 'self';`,
}
got := csp(&model.User{ExternalFontHosts: "test.com", CustomJS: "alert(1)"}, "1234")
for _, value := range want {
if !strings.Contains(got, value) {
t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
}
}
}
func TestCSPExternalFontStylesheet(t *testing.T) {
want := []string{
`default-src 'none';`,
`img-src * data:;`,
`media-src *;`,
`frame-src *;`,
`style-src 'nonce-1234' test.com;`,
`script-src 'nonce-1234'`,
`'strict-dynamic';`,
`require-trusted-types-for 'script';`,
`trusted-types html url;`,
`manifest-src 'self';`,
}
got := csp(&model.User{ExternalFontHosts: "test.com", Stylesheet: "a {color: red;}"}, "1234")
for _, value := range want {
if !strings.Contains(got, value) {
t.Errorf(`Unexpected result, didn't find %q in %q`, value, got)
}
}
}
+56 -62
View File
@@ -25,24 +25,18 @@
<link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
{{ if .user }}
{{ $cspNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types html url;">
{{ $cspNonce := nonce }}
{{ csp .user $cspNonce | safeHTML }}
<link rel="stylesheet" nonce="{{ $cspNonce }}" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
<script nonce="{{ $cspNonce }}" src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
{{ if .user -}}
{{ if .user.Stylesheet -}}
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ end -}}
{{ if .user.CustomJS -}}
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
{{ end -}}
{{ else -}}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types html url;">
{{ end -}}
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
</head>
<body
data-service-worker-url="{{ route "javascript" "name" "service-worker" "checksum" .sw_js_checksum }}"
@@ -132,62 +126,62 @@
<main id="main">
{{template "content" .}}
</main>
<template id="keyboard-shortcuts">
<div id="modal-left">
<button class="btn-close-modal" aria-label="Close">x</button>
<h3 tabindex="-1" id="dialog-title">{{ t "page.keyboard_shortcuts.title" }}</h3>
<dialog id="keyboard-shortcuts-modal" closedby="any">
<form method="dialog">
<button class="btn-close-modal" aria-label="Close" autofocus>x</button>
</form>
<h3 tabindex="-1" id="dialog-title">{{ t "page.keyboard_shortcuts.title" }}</h3>
<div class="keyboard-shortcuts">
<p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_unread" }} = <strong>g + u</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_starred" }} = <strong>g + b</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_history" }} = <strong>g + h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feeds" }} = <strong>g + f</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_categories" }} = <strong>g + c</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_settings" }} = <strong>g + s</strong></li>
<li>{{ t "page.keyboard_shortcuts.show_keyboard_shortcuts" }} = <strong>?</strong></li>
<li>{{ t "menu.add_feed" }} = <strong>+</strong></li>
</ul>
<div class="keyboard-shortcuts">
<p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_unread" }} = <strong>g + u</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_starred" }} = <strong>g + b</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_history" }} = <strong>g + h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feeds" }} = <strong>g + f</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_categories" }} = <strong>g + c</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_settings" }} = <strong>g + s</strong></li>
<li>{{ t "page.keyboard_shortcuts.show_keyboard_shortcuts" }} = <strong>?</strong></li>
<li>{{ t "menu.add_feed" }} = <strong>+</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.items" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_item" }} = <strong>p</strong>, <strong>k</strong>, <strong>&#x23F4;</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_item" }} = <strong>n</strong>, <strong>j</strong>, <strong>&#x23F5;</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feed" }} = <strong>F</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_top_item" }} = <strong>g + g</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_bottom_item" }} = <strong>G</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.items" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_item" }} = <strong>p</strong>, <strong>k</strong>, <strong>&#x23F4;</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_item" }} = <strong>n</strong>, <strong>j</strong>, <strong>&#x23F5;</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feed" }} = <strong>F</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_top_item" }} = <strong>g + g</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_bottom_item" }} = <strong>G</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.pages" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_page" }} = <strong>h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_page" }} = <strong>l</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.pages" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_page" }} = <strong>h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_page" }} = <strong>l</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.actions" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.open_item" }} = <strong>o</strong>, <strong>Enter</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original" }} = <strong>v</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original_same_window" }} = <strong>V</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments" }} = <strong>c</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments_same_window" }} = <strong>C</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_next" }} = <strong>m</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_prev" }} = <strong>M</strong></li>
<li>{{ t "page.keyboard_shortcuts.mark_page_as_read" }} = <strong>A</strong></li>
<li>{{ t "page.keyboard_shortcuts.download_content" }} = <strong>d</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_star_status" }} = <strong>f</strong></li>
<li>{{ t "page.keyboard_shortcuts.save_article" }} = <strong>s</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_entry_attachments" }} = <strong>a</strong></li>
<li>{{ t "page.keyboard_shortcuts.scroll_item_to_top" }} = <strong>z + t</strong></li>
<li>{{ t "page.keyboard_shortcuts.refresh_all_feeds" }} = <strong>R</strong></li>
<li>{{ t "page.keyboard_shortcuts.remove_feed" }} = <strong>#</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_search" }} = <strong>/</strong></li>
<li>{{ t "page.keyboard_shortcuts.close_modal" }} = <strong>Esc</strong></li>
</ul>
</div>
<p>{{ t "page.keyboard_shortcuts.subtitle.actions" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.open_item" }} = <strong>o</strong>, <strong>Enter</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original" }} = <strong>v</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original_same_window" }} = <strong>V</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments" }} = <strong>c</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments_same_window" }} = <strong>C</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_next" }} = <strong>m</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_prev" }} = <strong>M</strong></li>
<li>{{ t "page.keyboard_shortcuts.mark_page_as_read" }} = <strong>A</strong></li>
<li>{{ t "page.keyboard_shortcuts.download_content" }} = <strong>d</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_star_status" }} = <strong>f</strong></li>
<li>{{ t "page.keyboard_shortcuts.save_article" }} = <strong>s</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_entry_attachments" }} = <strong>a</strong></li>
<li>{{ t "page.keyboard_shortcuts.scroll_item_to_top" }} = <strong>z + t</strong></li>
<li>{{ t "page.keyboard_shortcuts.refresh_all_feeds" }} = <strong>R</strong></li>
<li>{{ t "page.keyboard_shortcuts.remove_feed" }} = <strong>#</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_search" }} = <strong>/</strong></li>
<li>{{ t "page.keyboard_shortcuts.close_modal" }} = <strong>Esc</strong></li>
</ul>
</div>
</template>
</dialog>
<template id="icon-read">{{ icon "read" }}</template>
<template id="icon-unread">{{ icon "unread" }}</template>
@@ -15,6 +15,18 @@
<div role="alert" class="alert alert-error">{{ .errorMessage }}</div>
{{ end }}
<details {{ if .form.ArchiveorgEnabled }}open{{ end }}>
<summary>Archive.org</summary>
<div class="form-section">
<label>
<input type="checkbox" name="archiveorg_enabled" value="1" {{ if .form.ArchiveorgEnabled }}checked{{ end }}> {{ t "form.integration.archiveorg_activate" }}
</label>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.AppriseEnabled }}open{{ end }}>
<summary>Apprise</summary>
<div class="form-section">
@@ -408,6 +420,9 @@
<label for="form-karakeep-url">{{ t "form.integration.karakeep_url" }}</label>
<input type="url" name="karakeep_url" id="form-karakeep-url" value="{{ .form.KarakeepURL }}" placeholder="https://try.karakeep.app/api/v1/bookmarks" spellcheck="false">
<label for="form-karakeep-tags">{{ t "form.integration.karakeep_tags" }}</label>
<input type="text" name="karakeep_tags" id="form-karakeep-tags" value="{{ .form.KarakeepTags }}" placeholder="miniflux, new" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
@@ -8,6 +8,7 @@
{{ if not disableLocalAuth }}
<form action="{{ route "checkLogin" }}" method="post">
<input type="hidden" name="csrf" value="{{ .csrf }}">
<input type="hidden" name="redirect_url" value="{{ .redirectURL }}">
{{ if .errorMessage }}
<div role="alert" class="alert alert-error">{{ .errorMessage }}</div>
+6
View File
@@ -103,6 +103,7 @@ type IntegrationForm struct {
KarakeepEnabled bool
KarakeepAPIKey string
KarakeepURL string
KarakeepTags string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
@@ -129,6 +130,7 @@ type IntegrationForm struct {
PushoverToken string
PushoverDevice string
PushoverPrefix string
ArchiveorgEnabled bool
}
// Merge copy form values to the model.
@@ -221,6 +223,7 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.KarakeepEnabled = i.KarakeepEnabled
integration.KarakeepAPIKey = i.KarakeepAPIKey
integration.KarakeepURL = i.KarakeepURL
integration.KarakeepTags = i.KarakeepTags
integration.RaindropEnabled = i.RaindropEnabled
integration.RaindropToken = i.RaindropToken
integration.RaindropCollectionID = i.RaindropCollectionID
@@ -247,6 +250,7 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.PushoverToken = i.PushoverToken
integration.PushoverDevice = i.PushoverDevice
integration.PushoverPrefix = i.PushoverPrefix
integration.ArchiveorgEnabled = i.ArchiveorgEnabled
}
// NewIntegrationForm returns a new IntegrationForm.
@@ -342,6 +346,7 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
KarakeepEnabled: r.FormValue("karakeep_enabled") == "1",
KarakeepAPIKey: r.FormValue("karakeep_api_key"),
KarakeepURL: r.FormValue("karakeep_url"),
KarakeepTags: r.FormValue("karakeep_tags"),
RaindropEnabled: r.FormValue("raindrop_enabled") == "1",
RaindropToken: r.FormValue("raindrop_token"),
RaindropCollectionID: r.FormValue("raindrop_collection_id"),
@@ -368,6 +373,7 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
PushoverToken: r.FormValue("pushover_token"),
PushoverDevice: r.FormValue("pushover_device"),
PushoverPrefix: r.FormValue("pushover_prefix"),
ArchiveorgEnabled: r.FormValue("archiveorg_enabled") == "1",
}
}
+3 -3
View File
@@ -166,15 +166,15 @@ func (s *SettingsForm) Validate() *locale.LocalizedError {
// NewSettingsForm returns a new SettingsForm.
func NewSettingsForm(r *http.Request) *SettingsForm {
entriesPerPage, err := strconv.ParseInt(r.FormValue("entries_per_page"), 10, 0)
entriesPerPage, err := strconv.Atoi(r.FormValue("entries_per_page"))
if err != nil {
entriesPerPage = 0
}
defaultReadingSpeed, err := strconv.ParseInt(r.FormValue("default_reading_speed"), 10, 0)
defaultReadingSpeed, err := strconv.Atoi(r.FormValue("default_reading_speed"))
if err != nil {
defaultReadingSpeed = 0
}
cjkReadingSpeed, err := strconv.ParseInt(r.FormValue("cjk_reading_speed"), 10, 0)
cjkReadingSpeed, err := strconv.Atoi(r.FormValue("cjk_reading_speed"))
if err != nil {
cjkReadingSpeed = 0
}
+2
View File
@@ -116,6 +116,7 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
KarakeepEnabled: integration.KarakeepEnabled,
KarakeepAPIKey: integration.KarakeepAPIKey,
KarakeepURL: integration.KarakeepURL,
KarakeepTags: integration.KarakeepTags,
RaindropEnabled: integration.RaindropEnabled,
RaindropToken: integration.RaindropToken,
RaindropCollectionID: integration.RaindropCollectionID,
@@ -142,6 +143,7 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
PushoverToken: integration.PushoverToken,
PushoverDevice: integration.PushoverDevice,
PushoverPrefix: integration.PushoverPrefix,
ArchiveorgEnabled: integration.ArchiveorgEnabled,
}
sess := session.New(h.store, request.SessionID(r))
+10
View File
@@ -6,6 +6,7 @@ package ui // import "miniflux.app/v2/internal/ui"
import (
"log/slog"
"net/http"
"net/url"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/cookie"
@@ -22,6 +23,8 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
redirectURL := r.FormValue("redirect_url")
view.Set("redirectURL", redirectURL)
if config.Opts.DisableLocalAuth() {
slog.Warn("blocking local auth login attempt, local auth is disabled",
@@ -93,5 +96,12 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
config.Opts.BasePath(),
))
if redirectURL != "" {
if parsedURL, err := url.Parse(redirectURL); err == nil && !parsedURL.IsAbs() {
html.Redirect(w, r, redirectURL)
return
}
}
html.Redirect(w, r, route.Path(h.router, user.DefaultHomePage))
}

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