Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 385d8bb969 | |||
| c171da1734 | |||
| 11ea137027 | |||
| 0a15075c00 | |||
| b1fda599ac | |||
| 509b7682ad | |||
| 06c2e50ffa | |||
| ec93656ef5 | |||
| 317aaeeec7 | |||
| d862f79123 | |||
| 1bea41b19c | |||
| 65a39096e7 | |||
| 131dc674e4 | |||
| b0dced42c0 | |||
| a34e33b5c5 | |||
| af12fe309e | |||
| dd44fbcc76 | |||
| 9c956d1b0d | |||
| bf7f55e28a | |||
| b8bc367a00 | |||
| 04a360a536 | |||
| fac18d5c57 | |||
| a3d1ecc58a | |||
| 8adcaed29e | |||
| 5a97bf8b5e | |||
| e279b955c4 | |||
| 1620f8d3f2 | |||
| 1a29c1568c | |||
| ff07f02716 | |||
| 79b0d0b9cc | |||
| 5fa0709663 | |||
| a7fa2ecc8c |
@@ -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
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Απενεργοποίηση ελέγχου συνδέσμου",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отключить проверку ссылок",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "禁用链接检查",
|
||||
|
||||
@@ -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": "停用連結檢查",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")`,
|
||||
|
||||
@@ -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&height=600&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&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>`,
|
||||
}
|
||||
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&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" 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&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&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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>⏴</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.go_to_next_item" }} = <strong>n</strong>, <strong>j</strong>, <strong>⏵</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>⏴</strong></li>
|
||||
<li>{{ t "page.keyboard_shortcuts.go_to_next_item" }} = <strong>n</strong>, <strong>j</strong>, <strong>⏵</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>
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user