Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c166f80b58 | |||
| 075127c56f | |||
| 34fcf0e54f | |||
| 7f4a1094f4 | |||
| 7846ddb3d7 | |||
| 6ff4f5ac67 | |||
| bf738d4b7f | |||
| 7242890d96 | |||
| b624cfd165 | |||
| bce1697c95 | |||
| 14462c4b2e | |||
| 3ae595ea1c | |||
| 4d76de1722 | |||
| 171ed7d2f6 | |||
| d9a97e268d | |||
| 365f2e6ee7 | |||
| 210993e146 | |||
| fe2bfb27cf | |||
| f9b756ecf8 | |||
| e8b2785329 | |||
| df7fc1e853 | |||
| 26824211aa | |||
| 0e13773d25 | |||
| 8d01ca3637 | |||
| 8b84a86f0c | |||
| be49cba34b | |||
| d7e049f187 | |||
| 4d7e8b2796 | |||
| 7f7f23d33d | |||
| 01888d8e32 | |||
| 223d29e5d9 | |||
| d58cb5b72c | |||
| 313f23fd65 | |||
| 54df471d0e | |||
| 72784c4943 | |||
| 7fa38aafe7 | |||
| fb7f16ecf2 | |||
| 8898c42f24 | |||
| 0054314355 | |||
| 28441d1e27 | |||
| 8d19529948 | |||
| 6838f3a6e9 | |||
| 8483f06595 | |||
| da15700205 | |||
| cd6236b1cd | |||
| 7b07b8256b | |||
| d3a1e7dbec | |||
| 7edf42fe18 | |||
| 5513827cdd | |||
| 052ad27e38 | |||
| 96c45874f5 | |||
| 3e022fb86e | |||
| aa814491f6 | |||
| 90c2199f0f | |||
| d40e523d25 | |||
| 46e77eccb0 | |||
| f19fc2ff53 | |||
| 0201c80db1 | |||
| 0022d0646b | |||
| 253119ed5f | |||
| 2619eba43c | |||
| 31f4f366a9 | |||
| fc9cb9e5d5 | |||
| 9b859e7f3f | |||
| 6f3c92db74 | |||
| 0dcc0a852b | |||
| 7b65255757 | |||
| 6a9d7894d1 | |||
| f3d0e0378b | |||
| 4d27f66ff9 | |||
| 5a8431b144 | |||
| 9e760e14cd | |||
| 879e40670c | |||
| bf24fd1ec9 | |||
| b57ebac757 | |||
| acfb7dc213 | |||
| 62f316c7cf | |||
| 89638b448a | |||
| d8181b1f65 | |||
| ce6cd0b2b6 | |||
| ba05c96d88 | |||
| 58674b9277 | |||
| 6b1f208209 | |||
| 7c41128d88 | |||
| 3aea68e725 | |||
| a5fb91e3e8 | |||
| 8357e6decf | |||
| ad4ca71f68 | |||
| 660c79bbdd | |||
| 2fa8995a35 | |||
| ffed2b4fa1 | |||
| 9fc69fd2e0 | |||
| cddd2eb168 | |||
| 33b780d9c0 | |||
| 5d8540b324 | |||
| a2a5244387 | |||
| 3a232d0c8d | |||
| 5a8df8abf0 | |||
| a4883ca11e | |||
| 4cd66aef5f | |||
| 0ab3bb5efb | |||
| 71c551ffe0 | |||
| bb05b25530 | |||
| 7b7fd48e15 | |||
| fb0fcfb266 | |||
| 39840bb0d2 | |||
| 79a3369699 | |||
| 696b98bb16 | |||
| 85fa69ba38 | |||
| c5a9111d58 | |||
| a46afe6979 | |||
| 2f67a68e75 | |||
| 83ca211cfb | |||
| 55aa15dd52 | |||
| e54e6c097c | |||
| 56a11d1d45 | |||
| df28028b24 |
@@ -9,6 +9,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -13,7 +13,8 @@ on:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux' )
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -21,9 +22,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -42,9 +43,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -53,7 +54,7 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
@@ -68,9 +69,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
if: github.repository_owner == 'miniflux'
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -50,21 +51,21 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -72,14 +73,14 @@ jobs:
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -89,7 +90,7 @@ jobs:
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Match
|
||||
|
||||
# Conventional commit pattern (including Git revert messages)
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = (
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ linters:
|
||||
- loggercheck
|
||||
- misspell
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- whitespace
|
||||
|
||||
@@ -113,6 +113,8 @@ integration-test:
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
LOG_LEVEL=debug \
|
||||
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ Miniflux API Client
|
||||
|
||||
[](https://pkg.go.dev/miniflux.app/v2/client)
|
||||
|
||||
Client library for Miniflux REST API.
|
||||
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
+16
-14
@@ -22,6 +22,8 @@ type Client struct {
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
//
|
||||
//go:fix inline
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
}
|
||||
@@ -334,14 +336,14 @@ func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
// IntegrationsStatus fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IntegrationsStatusContext(ctx)
|
||||
}
|
||||
|
||||
// IntegrationsStatusContext fetches the integrations status for the logged user.
|
||||
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/integrations/status")
|
||||
if err != nil {
|
||||
@@ -360,7 +362,7 @@ func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
// Discover tries to find subscriptions on a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -383,14 +385,14 @@ func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Categories gets the list of categories.
|
||||
// Categories retrieves the list of categories.
|
||||
func (c *Client) Categories() (Categories, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesContext gets the list of categories.
|
||||
// CategoriesContext retrieves the list of categories.
|
||||
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories")
|
||||
if err != nil {
|
||||
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
// 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),
|
||||
Title: new(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -537,14 +539,14 @@ func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
// CategoryFeeds returns all feeds for a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryFeedsContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// CategoryFeedsContext gets feeds of a category.
|
||||
// CategoryFeedsContext returns all feeds for 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 {
|
||||
@@ -608,14 +610,14 @@ func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// Export creates OPML file.
|
||||
// Export exports subscriptions as an OPML document.
|
||||
func (c *Client) Export() ([]byte, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ExportContext(ctx)
|
||||
}
|
||||
|
||||
// ExportContext creates OPML file.
|
||||
// ExportContext exports subscriptions as an OPML document.
|
||||
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/export")
|
||||
if err != nil {
|
||||
@@ -886,7 +888,7 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Entries fetch entries.
|
||||
// Entries fetches entries using the given filter.
|
||||
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -911,7 +913,7 @@ func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResu
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FeedEntries fetch feed entries.
|
||||
// FeedEntries fetches entries for a feed using the given filter.
|
||||
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -936,7 +938,7 @@ func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *F
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CategoryEntries fetch entries of a category.
|
||||
// CategoryEntries fetches entries for a category using the given filter.
|
||||
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
@@ -1002,7 +1004,7 @@ func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryCha
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleStarred toggles entry starred value.
|
||||
// ToggleStarred toggles the starred flag of an entry.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
|
||||
|
||||
# Examples
|
||||
|
||||
This code snippet fetch the list of users:
|
||||
This example fetches the list of users:
|
||||
|
||||
import (
|
||||
miniflux "miniflux.app/v2/client"
|
||||
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
|
||||
}
|
||||
fmt.Println(users, err)
|
||||
|
||||
This one discover subscriptions on a website:
|
||||
This example discovers subscriptions on a website:
|
||||
|
||||
subscriptions, err := client.Discover("https://example.org/")
|
||||
if err != nil {
|
||||
|
||||
+8
-2
@@ -149,7 +149,7 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
@@ -166,6 +166,7 @@ type Feed struct {
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
@@ -185,6 +186,7 @@ type FeedCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
@@ -214,6 +216,7 @@ type FeedModificationRequest struct {
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
@@ -356,6 +359,9 @@ type APIKeyCreationRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
|
||||
//
|
||||
//go:fix inline
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.24
|
||||
// +heroku goVersion go1.26
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/go-webauthn/webauthn v0.16.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.8
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/term v0.38.0
|
||||
github.com/tdewolff/minify/v2 v2.24.10
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
github.com/go-webauthn/x v0.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -30,21 +31,18 @@ require (
|
||||
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.1.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.5 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
go 1.26.0
|
||||
|
||||
@@ -17,19 +17,21 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
|
||||
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
|
||||
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
|
||||
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
@@ -42,8 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -60,10 +62,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.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
|
||||
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
|
||||
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
|
||||
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=
|
||||
github.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=
|
||||
github.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=
|
||||
github.com/tdewolff/parse/v2 v2.8.10/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 +85,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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
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 +103,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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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 +125,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/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 +136,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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/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 +147,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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/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=
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -609,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
ExternalFontHosts: new(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -638,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
ExternalFontHosts: new("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
@@ -662,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
CustomJS: new("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -691,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("invalid_theme"),
|
||||
Theme: new("invalid_theme"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -721,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
|
||||
@@ -1090,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("new title"),
|
||||
Title: new("new title"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1109,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(true),
|
||||
HideGlobally: new(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1128,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(false),
|
||||
HideGlobally: new(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
|
||||
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
@@ -1612,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
|
||||
FeedURL: new("https://example.org/feed.xml"),
|
||||
}
|
||||
|
||||
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
|
||||
@@ -1653,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
|
||||
FeedURL: new(testConfig.testFeedURL),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -1685,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
CategoryID: miniflux.SetOptionalField(int64(123456789)),
|
||||
CategoryID: new(int64(123456789)),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -2718,8 +2718,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
entryUpdateRequest := &miniflux.EntryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("New title"),
|
||||
Content: miniflux.SetOptionalField("New content"),
|
||||
Title: new("New title"),
|
||||
Content: new("New content"),
|
||||
}
|
||||
|
||||
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
|
||||
|
||||
+24
-24
@@ -308,47 +308,47 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req EntryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
var importRequest entryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
if importRequest.URL == "" {
|
||||
json.BadRequest(w, r, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
req.Status = model.EntryStatusRead
|
||||
if importRequest.Status == "" {
|
||||
importRequest.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryStatus(req.Status); err != nil {
|
||||
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := model.NewEntry()
|
||||
entry.URL = req.URL
|
||||
entry.CommentsURL = req.CommentsURL
|
||||
entry.Author = req.Author
|
||||
entry.Tags = req.Tags
|
||||
entry.URL = importRequest.URL
|
||||
entry.CommentsURL = importRequest.CommentsURL
|
||||
entry.Author = importRequest.Author
|
||||
entry.Tags = importRequest.Tags
|
||||
|
||||
if req.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(req.PublishedAt, 0).UTC()
|
||||
if importRequest.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
|
||||
} else {
|
||||
entry.Date = time.Now().UTC()
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
if importRequest.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
} else {
|
||||
entry.Title = req.Title
|
||||
entry.Title = importRequest.Title
|
||||
}
|
||||
|
||||
hashInput := req.ExternalID
|
||||
hashInput := importRequest.ExternalID
|
||||
if hashInput == "" {
|
||||
hashInput = req.URL
|
||||
hashInput = importRequest.URL
|
||||
}
|
||||
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
|
||||
|
||||
@@ -363,8 +363,8 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, req.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
if importRequest.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
}
|
||||
|
||||
if user.ShowReadingTime {
|
||||
@@ -377,13 +377,13 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, req.Status); err != nil {
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Status = req.Status
|
||||
entry.Status = importRequest.Status
|
||||
|
||||
if req.Starred {
|
||||
if importRequest.Starred {
|
||||
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -392,9 +392,9 @@ func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if created {
|
||||
json.Created(w, r, map[string]int64{"id": entry.ID})
|
||||
json.Created(w, r, entryIDResponse{ID: entry.ID})
|
||||
} else {
|
||||
json.OK(w, r, map[string]int64{"id": entry.ID})
|
||||
json.OK(w, r, entryIDResponse{ID: entry.ID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
json.OK(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -13,11 +13,6 @@ import (
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
|
||||
@@ -32,5 +32,5 @@ func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
|
||||
json.Created(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
}
|
||||
|
||||
+29
-13
@@ -18,24 +18,40 @@ type entriesResponse struct {
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
// EntryImportRequest represents a manually imported entry for a feed.
|
||||
type EntryImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Status string `json:"status"`
|
||||
Starred bool `json:"starred"`
|
||||
Tags []string `json:"tags"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
type integrationsStatusResponse struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
type entryIDResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type entryContentResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
}
|
||||
|
||||
type entryImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Status string `json:"status"`
|
||||
Starred bool `json:"starred"`
|
||||
Tags []string `json:"tags"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
type importFeedsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
|
||||
@@ -126,13 +126,7 @@ func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
json.OK(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
|
||||
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
|
||||
+25
-24
@@ -5,6 +5,7 @@ package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -218,6 +219,11 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"FETCH_BILIBILI_WATCH_TIME": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
@@ -238,14 +244,6 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"FILTER_ENTRY_MAX_AGE_DAYS": {
|
||||
parsedIntValue: 0,
|
||||
rawValue: "0",
|
||||
valueType: intType,
|
||||
validator: func(rawValue string) error {
|
||||
return validateGreaterOrEqualThan(rawValue, 0)
|
||||
},
|
||||
},
|
||||
"FORCE_REFRESH_INTERVAL": {
|
||||
parsedDuration: 30 * time.Minute,
|
||||
rawValue: "30",
|
||||
@@ -300,7 +298,7 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
|
||||
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
@@ -360,11 +358,6 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "",
|
||||
valueType: urlType,
|
||||
},
|
||||
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
|
||||
parsedDuration: 120 * time.Second,
|
||||
rawValue: "120",
|
||||
@@ -572,6 +565,15 @@ func NewConfigOptions() *configOptions {
|
||||
parsedStringList: []string{},
|
||||
rawValue: "",
|
||||
valueType: stringListType,
|
||||
validator: func(rawValue string) error {
|
||||
for ip := range strings.SplitSeq(rawValue, ",") {
|
||||
if _, _, err := net.ParseCIDR(ip); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
"WATCHDOG": {
|
||||
parsedBoolValue: true,
|
||||
@@ -718,10 +720,6 @@ func (c *configOptions) FetchYouTubeWatchTime() bool {
|
||||
return c.options["FETCH_YOUTUBE_WATCH_TIME"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) FilterEntryMaxAgeDays() int {
|
||||
return c.options["FILTER_ENTRY_MAX_AGE_DAYS"].parsedIntValue
|
||||
}
|
||||
|
||||
func (c *configOptions) ForceRefreshInterval() time.Duration {
|
||||
return c.options["FORCE_REFRESH_INTERVAL"].parsedDuration
|
||||
}
|
||||
@@ -793,8 +791,15 @@ func (c *configOptions) HTTPS() bool {
|
||||
return c.options["HTTPS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
|
||||
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
|
||||
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) InvidiousInstance() string {
|
||||
@@ -849,10 +854,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
|
||||
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
|
||||
}
|
||||
|
||||
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
|
||||
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
|
||||
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
|
||||
}
|
||||
|
||||
@@ -1273,22 +1273,6 @@ func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.FilterEntryMaxAgeDays() != 0 {
|
||||
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.FilterEntryMaxAgeDays() != 7 {
|
||||
t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1367,27 +1351,51 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
if !configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,30 +1466,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1659,6 +1643,11 @@ func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
|
||||
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
|
||||
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
|
||||
}
|
||||
|
||||
// Test invalid value
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
|
||||
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -119,6 +120,9 @@ func (cp *configParser) parseLines(lines []string) error {
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
|
||||
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
|
||||
}
|
||||
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
|
||||
hasExtra := false
|
||||
if err := tx.QueryRow(`
|
||||
SELECT true
|
||||
SELECT true
|
||||
FROM information_schema.columns
|
||||
WHERE
|
||||
table_name='users' AND
|
||||
@@ -1403,4 +1403,32 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE user_sessions
|
||||
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
|
||||
ALTER COLUMN ip SET NOT NULL,
|
||||
ALTER COLUMN created_at SET DEFAULT now(),
|
||||
ALTER COLUMN created_at SET NOT NULL,
|
||||
ALTER COLUMN user_agent SET DEFAULT '',
|
||||
ALTER COLUMN user_agent SET NOT NULL
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
if csvItemIDs != "" {
|
||||
var itemIDs []int64
|
||||
|
||||
for _, strItemID := range strings.Split(csvItemIDs, ",") {
|
||||
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
|
||||
strItemID = strings.TrimSpace(strItemID)
|
||||
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
|
||||
@@ -38,6 +38,7 @@ var (
|
||||
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
|
||||
errFeedNotFound = errors.New("googlereader: feed not found")
|
||||
errCategoryNotFound = errors.New("googlereader: category not found")
|
||||
errSimultaneously = fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
)
|
||||
|
||||
// Serve handles Google Reader API calls.
|
||||
@@ -69,12 +70,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[KeptUnreadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case StarredStream:
|
||||
@@ -89,12 +90,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
return nil, errSimultaneously
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case StarredStream:
|
||||
@@ -452,7 +453,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, quickAddResponse{
|
||||
NumResults: 1,
|
||||
Query: newFeed.FeedURL,
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
|
||||
StreamID: feedPrefix + strconv.FormatInt(newFeed.ID, 10),
|
||||
StreamName: newFeed.Title,
|
||||
})
|
||||
}
|
||||
@@ -584,9 +585,8 @@ func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID
|
||||
func (h *handler) feedIconURL(f *model.Feed) string {
|
||||
if f.Icon != nil && f.Icon.ExternalIconID != "" {
|
||||
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -697,9 +697,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
|
||||
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
|
||||
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
|
||||
streamPrefix := fmt.Sprintf(userStreamPrefix, userID)
|
||||
userReadingList := streamPrefix + readingListStreamSuffix
|
||||
userRead := streamPrefix + readStreamSuffix
|
||||
userStarred := streamPrefix + starredStreamSuffix
|
||||
|
||||
itemIDs, err := parseItemIDsFromRequest(r)
|
||||
if err != nil {
|
||||
@@ -739,6 +740,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Items: make([]contentItem, len(entries)),
|
||||
}
|
||||
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
for i, entry := range entries {
|
||||
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
@@ -747,7 +749,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories := make([]string, 0)
|
||||
categories = append(categories, userReadingList)
|
||||
if entry.Feed.Category.Title != "" {
|
||||
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
categories = append(categories, labelPrefix+entry.Feed.Category.Title)
|
||||
}
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
categories = append(categories, userRead)
|
||||
@@ -789,7 +791,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Content: entry.Content,
|
||||
},
|
||||
Origin: contentItemOrigin{
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
|
||||
StreamID: feedPrefix + strconv.FormatInt(entry.FeedID, 10),
|
||||
Title: entry.Feed.Title,
|
||||
HTMLUrl: entry.Feed.SiteURL,
|
||||
},
|
||||
@@ -890,7 +892,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
categoryModificationRequest := model.CategoryModificationRequest{
|
||||
Title: model.SetOptionalField(destination.ID),
|
||||
Title: new(destination.ID),
|
||||
}
|
||||
|
||||
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
|
||||
@@ -933,9 +935,10 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
|
||||
})
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
for _, category := range categories {
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
|
||||
ID: labelPrefix + category.Title,
|
||||
Label: category.Title,
|
||||
Type: "folder",
|
||||
})
|
||||
@@ -965,13 +968,14 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
labelPrefix := fmt.Sprintf(userLabelPrefix, userID)
|
||||
result.Subscriptions = make([]subscriptionResponse, 0)
|
||||
for _, feed := range feeds {
|
||||
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
|
||||
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
|
||||
ID: feedPrefix + strconv.FormatInt(feed.ID, 10),
|
||||
Title: feed.Title,
|
||||
URL: feed.FeedURL,
|
||||
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
Categories: []subscriptionCategoryResponse{{labelPrefix + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
HTMLURL: feed.SiteURL,
|
||||
IconURL: h.feedIconURL(feed),
|
||||
})
|
||||
@@ -1067,7 +1071,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
slog.Debug("[GoogleReader] Handle ReadingListStream",
|
||||
@@ -1102,31 +1106,15 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0)
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
}
|
||||
|
||||
totalEntries, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
continuation := 0
|
||||
if len(itemRefs)+rm.Offset < totalEntries {
|
||||
continuation = len(itemRefs) + rm.Offset
|
||||
}
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
@@ -1139,32 +1127,15 @@ func (h *handler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Requ
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0)
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
}
|
||||
|
||||
totalEntries, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
continuation := 0
|
||||
if len(itemRefs)+rm.Offset < totalEntries {
|
||||
continuation = len(itemRefs) + rm.Offset
|
||||
}
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
@@ -1178,12 +1149,20 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0)
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestModifiers) ([]itemRef, int, error) {
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
@@ -1191,18 +1170,16 @@ func (h *handler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
totalEntries, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
return nil, 0, err
|
||||
}
|
||||
continuation := 0
|
||||
if len(itemRefs)+rm.Offset < totalEntries {
|
||||
continuation = len(itemRefs) + rm.Offset
|
||||
}
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
return itemRefs, continuation, nil
|
||||
}
|
||||
|
||||
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
|
||||
func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
feedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
@@ -1231,30 +1208,11 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var itemRefs = make([]itemRef, 0)
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
}
|
||||
|
||||
totalEntries, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
continuation := 0
|
||||
if len(itemRefs)+rm.Offset < totalEntries {
|
||||
continuation = len(itemRefs) + rm.Offset
|
||||
}
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
)
|
||||
|
||||
type RequestModifiers struct {
|
||||
type requestModifiers struct {
|
||||
ExcludeTargets []Stream
|
||||
FilterTargets []Stream
|
||||
Streams []Stream
|
||||
@@ -24,7 +24,7 @@ type RequestModifiers struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (r RequestModifiers) String() string {
|
||||
func (r requestModifiers) String() string {
|
||||
var results []string
|
||||
|
||||
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
|
||||
@@ -57,9 +57,9 @@ func (r RequestModifiers) String() string {
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
func parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {
|
||||
userID := request.UserID(r)
|
||||
result := RequestModifiers{
|
||||
result := requestModifiers{
|
||||
SortDirection: "desc",
|
||||
UserID: userID,
|
||||
}
|
||||
@@ -71,16 +71,16 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/internal/http/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
|
||||
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
|
||||
|
||||
// Options holds configuration for creating an HTTP client.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// NewClientWithOptions creates a new HTTP client with the specified options.
|
||||
func NewClientWithOptions(opts Options) *http.Client {
|
||||
if !opts.BlockPrivateNetworks {
|
||||
return &http.Client{Timeout: opts.Timeout}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: opts.Timeout,
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
|
||||
}
|
||||
|
||||
var safeIP net.IP
|
||||
for _, ip := range ips {
|
||||
if !urllib.IsNonPublicIP(ip) {
|
||||
safeIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if safeIP == nil {
|
||||
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
|
||||
}
|
||||
|
||||
safeAddr := net.JoinHostPort(safeIP.String(), port)
|
||||
return dialer.DialContext(ctx, network, safeAddr)
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err := client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error when connecting to loopback address, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
if client.Transport != nil {
|
||||
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create listener: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err = client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when connecting to private IP")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsTrustedIP checks if the given remote IP belongs to one of the trusted networks.
|
||||
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
|
||||
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
|
||||
if remoteIP == "@" || strings.HasPrefix(remoteIP, "/") {
|
||||
return true
|
||||
if len(trustedNetworks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
ip := net.ParseIP(remoteIP)
|
||||
@@ -34,7 +34,7 @@ func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
|
||||
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
|
||||
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
|
||||
if isTrustedProxyClient {
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
@@ -57,19 +57,35 @@ func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
|
||||
return FindRemoteIP(r)
|
||||
}
|
||||
|
||||
// FindRemoteIP returns remote client IP address without considering HTTP headers.
|
||||
// FindRemoteIP returns the parsed remote IP address from the request,
|
||||
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
|
||||
func FindRemoteIP(r *http.Request) string {
|
||||
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
remoteIP = r.RemoteAddr
|
||||
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return dropIPv6zone(remoteIP)
|
||||
|
||||
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
// Strip IPv6 zone identifier if present (e.g., %eth0).
|
||||
ip = dropIPv6zone(ip)
|
||||
|
||||
// Validate the IP address.
|
||||
if net.ParseIP(ip) == nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func dropIPv6zone(address string) string {
|
||||
i := strings.IndexByte(address, '%')
|
||||
if i != -1 {
|
||||
address = address[:i]
|
||||
idx := strings.IndexByte(address, '%')
|
||||
if idx != -1 {
|
||||
address = address[:idx]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ func TestFindClientIPWithoutHeaders(t *testing.T) {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "@"}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: ""}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
@@ -104,14 +114,6 @@ func TestClientIPWithBothHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "@"}
|
||||
|
||||
if ip := FindClientIP(r, false); ip != "@" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
|
||||
@@ -136,8 +138,9 @@ func TestIsTrustedIP(t *testing.T) {
|
||||
{"::1", true},
|
||||
{"192.168.1.1", false},
|
||||
{"invalid", false},
|
||||
{"@", true},
|
||||
{"/tmp/miniflux.sock", true},
|
||||
{"@", false},
|
||||
{"/tmp/miniflux.sock", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -155,3 +158,25 @@ func TestIsTrustedIP(t *testing.T) {
|
||||
t.Error("Expected false when trusted networks list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteIP(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected string
|
||||
}{
|
||||
{"192.168.0.1:4242", "192.168.0.1"},
|
||||
{"[2001:db8::1]:4242", "2001:db8::1"},
|
||||
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
|
||||
{"", "127.0.0.1"},
|
||||
{"@", "127.0.0.1"},
|
||||
{"invalid", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
r := &http.Request{RemoteAddr: scenario.ip}
|
||||
result := FindRemoteIP(r)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
|
||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||
if value, valid := v.(model.WebAuthnSession); valid {
|
||||
@@ -45,27 +46,27 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoogleReaderToken returns the google reader token if it exists.
|
||||
// GoogleReaderToken returns the Google Reader token from the request context, if present.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
// IsAdminUser reports whether the logged-in user is an administrator.
|
||||
func IsAdminUser(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAdminUserContextKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
// IsAuthenticated reports whether the user is authenticated.
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAuthenticatedContextKey)
|
||||
}
|
||||
|
||||
// UserID returns the UserID of the logged user.
|
||||
// UserID returns the logged-in user's ID from the request context.
|
||||
func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
}
|
||||
|
||||
// UserName returns the username of the logged user.
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
func UserName(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserNameContextKey)
|
||||
if value == "" {
|
||||
@@ -74,7 +75,7 @@ func UserName(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
|
||||
func UserTimezone(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserTimezoneContextKey)
|
||||
if value == "" {
|
||||
@@ -83,7 +84,7 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage get the locale used by the current logged user.
|
||||
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
@@ -92,7 +93,7 @@ func UserLanguage(r *http.Request) string {
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme get the theme used by the current logged user.
|
||||
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
@@ -101,41 +102,42 @@ func UserTheme(r *http.Request) string {
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the current CSRF token.
|
||||
// CSRF returns the CSRF token from the request context.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID.
|
||||
// SessionID returns the current session ID from the request context.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token.
|
||||
// UserSessionToken returns the current user session token from the request context.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the current OAuth2 state.
|
||||
// OAuth2State returns the OAuth2 state value from the request context.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the message message if any.
|
||||
// FlashMessage returns the flash message from the request context, if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the message error message if any.
|
||||
// FlashErrorMessage returns the flash error message from the request context, if any.
|
||||
func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
// LastForceRefresh returns the last force refresh timestamp from the request context.
|
||||
func LastForceRefresh(r *http.Request) time.Time {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
@@ -145,7 +147,7 @@ func LastForceRefresh(r *http.Request) time.Time {
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the context.
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestContextStringValue(t *testing.T) {
|
||||
@@ -192,6 +195,28 @@ func TestUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserName(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserName(r)
|
||||
expected := "unknown"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserName(r)
|
||||
expected = "jane"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTimezone(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
@@ -346,6 +371,28 @@ func TestOAuth2State(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2CodeVerifier(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2CodeVerifier(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2CodeVerifier(r)
|
||||
expected = "verifier"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
@@ -390,6 +437,67 @@ func TestFlashErrorMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastForceRefresh(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := LastForceRefresh(r)
|
||||
expected := time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Unix(1700000000, 0)
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnSessionData(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := model.WebAuthnSession{}
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result == nil {
|
||||
t.Errorf("Unexpected context value, got nil instead of session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
@@ -411,3 +519,25 @@ func TestClientIP(t *testing.T) {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleReaderToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := GoogleReaderToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = GoogleReaderToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CookieValue returns the cookie value.
|
||||
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// FormInt64Value returns a form value as integer.
|
||||
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
|
||||
func FormInt64Value(r *http.Request, param string) int64 {
|
||||
value := r.FormValue(param)
|
||||
integer, err := strconv.ParseInt(value, 10, 64)
|
||||
@@ -22,7 +22,7 @@ func FormInt64Value(r *http.Request, param string) int64 {
|
||||
return integer
|
||||
}
|
||||
|
||||
// RouteInt64Param returns an URL route parameter as int64.
|
||||
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
|
||||
func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
vars := mux.Vars(r)
|
||||
value, err := strconv.ParseInt(vars[param], 10, 64)
|
||||
@@ -37,13 +37,13 @@ func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// RouteStringParam returns a URL route parameter as string.
|
||||
// RouteStringParam returns the named route parameter as a string.
|
||||
func RouteStringParam(r *http.Request, param string) string {
|
||||
vars := mux.Vars(r)
|
||||
return vars[param]
|
||||
}
|
||||
|
||||
// QueryStringParam returns a query string parameter as string.
|
||||
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
|
||||
func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -52,7 +52,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryStringParamList returns all values associated to the parameter.
|
||||
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
|
||||
func QueryStringParamList(r *http.Request, param string) []string {
|
||||
var results []string
|
||||
values := r.URL.Query()
|
||||
@@ -69,7 +69,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
|
||||
return results
|
||||
}
|
||||
|
||||
// QueryIntParam returns a query string parameter as integer.
|
||||
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
|
||||
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -88,7 +88,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
return int(val)
|
||||
}
|
||||
|
||||
// QueryInt64Param returns a query string parameter as int64.
|
||||
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
|
||||
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -107,7 +107,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
return val
|
||||
}
|
||||
|
||||
// QueryBoolParam returns a query string parameter as bool.
|
||||
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
|
||||
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -123,7 +123,7 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
// HasQueryParam reports whether the query string contains the named parameter.
|
||||
func HasQueryParam(r *http.Request, param string) bool {
|
||||
values := r.URL.Query()
|
||||
_, ok := values[param]
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -179,7 +180,7 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryInt64Param(r, "invalid", int64(69))
|
||||
result = QueryInt64Param(r, "negative", int64(69))
|
||||
expected = int64(69)
|
||||
|
||||
if result != expected {
|
||||
@@ -194,6 +195,58 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBoolParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryBoolParam(r, "truthy", false)
|
||||
expected := true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "falsy", true)
|
||||
expected = false
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "missing", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "invalid", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryStringParamList(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryStringParamList(r, "tag")
|
||||
expected := []string{"alpha", "beta", "gamma"}
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryStringParamList(r, "missing")
|
||||
expected = nil
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasQueryParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?key=42")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui"
|
||||
"miniflux.app/v2/internal/version"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -72,22 +71,23 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
|
||||
isUNIXSocket := strings.HasPrefix(listenAddr, "/")
|
||||
isListenPID := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
|
||||
|
||||
if !isUNIXSocket && !isListenPID {
|
||||
server.Addr = listenAddr
|
||||
}
|
||||
|
||||
shouldAddServer := true
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
case isListenPID:
|
||||
if i == 0 {
|
||||
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
|
||||
startSystemdSocketServer(server)
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
|
||||
shouldAddServer = false
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.", slog.String("skipped_address", listenAddr))
|
||||
continue
|
||||
}
|
||||
case strings.HasPrefix(listenAddr, "/"): // Unix socket
|
||||
case isUNIXSocket:
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
|
||||
server.Addr = listenAddr
|
||||
@@ -101,9 +101,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
if shouldAddServer {
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
|
||||
return httpServers
|
||||
@@ -250,10 +248,6 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
|
||||
|
||||
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
|
||||
|
||||
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(version.Version))
|
||||
}).Name("version")
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -25,7 +27,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
return &Client{servicesURL: serviceURL, baseURL: baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -65,12 +67,12 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
package archiveorg
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
|
||||
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
|
||||
const options = "delay_wb_availability=1&if_not_archived_within=15d"
|
||||
|
||||
@@ -18,26 +24,25 @@ 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)
|
||||
func (c *Client) SendURL(entryURL string) error {
|
||||
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
|
||||
return fmt.Errorf("betula: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to send request: %v", err)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -77,12 +79,12 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
requestBody, err := json.Marshal(&espialDocument{
|
||||
Title: entryTitle,
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
ToRead: true,
|
||||
Tags: espialTags,
|
||||
})
|
||||
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to send request: %v", err)
|
||||
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
type espialDocument struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ToRead bool `json:"toread,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
request.SetBasicAuth(c.username, c.password)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instapaper: unable to send request: %v", err)
|
||||
|
||||
@@ -409,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
|
||||
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Archive.org",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
@@ -447,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
|
||||
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
|
||||
if err := client.SaveUrl(entry.URL); err != nil {
|
||||
if err := client.SaveURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Omnivore",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
@@ -543,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Warn("Unable to send new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
@@ -642,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
client := pushover.NewClient(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
@@ -658,30 +665,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if err := telegrambot.PushEntry(
|
||||
feed,
|
||||
entry,
|
||||
userIntegrations.TelegramBotToken,
|
||||
userIntegrations.TelegramBotChatID,
|
||||
userIntegrations.TelegramBotTopicID,
|
||||
userIntegrations.TelegramBotDisableWebPagePreview,
|
||||
userIntegrations.TelegramBotDisableNotification,
|
||||
userIntegrations.TelegramBotDisableButtons,
|
||||
); err != nil {
|
||||
slog.Error("Unable to send entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
|
||||
if err := telegrambot.PushEntry(
|
||||
feed,
|
||||
entry,
|
||||
userIntegrations.TelegramBotToken,
|
||||
userIntegrations.TelegramBotChatID,
|
||||
userIntegrations.TelegramBotTopicID,
|
||||
userIntegrations.TelegramBotDisableWebPagePreview,
|
||||
userIntegrations.TelegramBotDisableNotification,
|
||||
userIntegrations.TelegramBotDisableButtons,
|
||||
); err != nil {
|
||||
slog.Error("Unable to send entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,7 @@ type errorResponse struct {
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
}
|
||||
|
||||
func (c *Client) attachTags(entryID string) error {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Private: c.private,
|
||||
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to send request: %v", err)
|
||||
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
|
||||
type createItemRequest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Private bool `json:"is_private,omitempty"`
|
||||
CheckDisabled bool `json:"check_disabled,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkdingBookmark{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Unread: c.unread,
|
||||
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to send request: %v", err)
|
||||
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
type linkdingBookmark struct {
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
TagNames []string `json:"tag_names,omitempty"`
|
||||
Unread bool `json:"unread,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to send request: %v", err)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Article",
|
||||
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "Invalid input",
|
||||
},
|
||||
},
|
||||
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
},
|
||||
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that description was truncated
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
description := input["description"].(string)
|
||||
|
||||
if len(description) != maxDescriptionLength {
|
||||
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that only 10 tags were sent
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
tags := input["tags"].(string)
|
||||
|
||||
tagCount := len(strings.Split(tags, ","))
|
||||
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGraphQLMutation(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
// Test that the GraphQL mutation is properly formatted
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("Failed to parse request: %v", err)
|
||||
}
|
||||
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify variables structure
|
||||
variables, ok := req["variables"].(map[string]interface{})
|
||||
variables, ok := req["variables"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing variables field")
|
||||
}
|
||||
|
||||
input, ok := variables["input"].(map[string]interface{})
|
||||
input, ok := variables["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing input in variables")
|
||||
}
|
||||
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
|
||||
// Return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
|
||||
// Create a mock server that always returns success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
|
||||
_ = strings.Join(splitTags, ",")
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to send request: %v", err)
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
@@ -323,3 +326,21 @@ func TestNewClient(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -40,7 +42,7 @@ func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
@@ -89,7 +91,7 @@ func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*L
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
@@ -138,7 +140,7 @@ func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, te
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
|
||||
request.Header.Set("Notion-Version", "2022-06-28")
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to send request: %v", err)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -32,7 +33,16 @@ func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntf
|
||||
if ntfyURL == "" {
|
||||
ntfyURL = defaultNtfyURL
|
||||
}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
|
||||
return &Client{
|
||||
ntfyURL: ntfyURL,
|
||||
ntfyTopic: ntfyTopic,
|
||||
ntfyApiToken: ntfyApiToken,
|
||||
ntfyUsername: ntfyUsername,
|
||||
ntfyPassword: ntfyPassword,
|
||||
ntfyIconURL: ntfyIconURL,
|
||||
ntfyInternalLinks: ntfyInternalLinks,
|
||||
ntfyPriority: ntfyPriority,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -98,7 +108,7 @@ func (c *Client) makeRequest(payload any) error {
|
||||
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -43,7 +45,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
|
||||
ContentType: "text/html",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to encode request body: %v", err)
|
||||
return fmt.Errorf("nunux-keeper: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
@@ -55,7 +57,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -34,12 +36,6 @@ mutation SaveUrl($input: SaveUrlInput!) {
|
||||
}
|
||||
`
|
||||
|
||||
type SaveUrlInput struct {
|
||||
ClientRequestId string `json:"clientRequestId"`
|
||||
Source string `json:"source"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
@@ -49,31 +45,27 @@ type errorResponse struct {
|
||||
type successResponse struct {
|
||||
Data struct {
|
||||
SaveUrl struct {
|
||||
Url string `json:"url"`
|
||||
ClientRequestId string `json:"clientRequestId"`
|
||||
URL string `json:"url"`
|
||||
ClientRequestID string `json:"clientRequestId"`
|
||||
} `json:"saveUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
SaveUrl(url string) error
|
||||
}
|
||||
|
||||
type client struct {
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string) Client {
|
||||
func NewClient(apiToken string, apiEndpoint string) *Client {
|
||||
if apiEndpoint == "" {
|
||||
apiEndpoint = defaultApiEndpoint
|
||||
}
|
||||
|
||||
return &client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
}
|
||||
|
||||
func (c *client) SaveUrl(url string) error {
|
||||
func (c *Client) SaveURL(url string) error {
|
||||
var payload = map[string]any{
|
||||
"query": mutation,
|
||||
"variables": map[string]any{
|
||||
@@ -105,7 +97,7 @@ func (c *client) SaveUrl(url string) error {
|
||||
defer resp.Body.Close()
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("omnivore: failed to parse response: %s", err)
|
||||
return fmt.Errorf("omnivore: failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
@@ -113,11 +105,14 @@ func (c *client) SaveUrl(url string) error {
|
||||
if err = json.Unmarshal(b, &errResponse); err != nil {
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
|
||||
if len(errResponse.Errors) > 0 {
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, errResponse.Errors[0].Message)
|
||||
}
|
||||
return fmt.Errorf("omnivore: failed to save URL: status=%d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var successReponse successResponse
|
||||
if err = json.Unmarshal(b, &successReponse); err != nil {
|
||||
var successResp successResponse
|
||||
if err = json.Unmarshal(b, &successResp); err != nil {
|
||||
return fmt.Errorf("omnivore: failed to parse response, however the request appears successful, is the url correct?: status=%d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -57,10 +58,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
|
||||
return fmt.Errorf("pinboard: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pinboard: unable to send request: %v", err)
|
||||
@@ -90,10 +90,9 @@ func (c *Client) getBookmark(entryURL string) (*Post, error) {
|
||||
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
|
||||
type Post struct {
|
||||
XMLName xml.Name `xml:"post"`
|
||||
Url string `xml:"href,attr"`
|
||||
URL string `xml:"href,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
Tags string `xml:"tag,attr"`
|
||||
Extended string `xml:"extended,attr"`
|
||||
@@ -30,7 +30,7 @@ type posts struct {
|
||||
|
||||
func NewPost(url string, description string) *Post {
|
||||
return &Post{
|
||||
Url: url,
|
||||
URL: url,
|
||||
Description: description,
|
||||
Date: time.Now(),
|
||||
Toread: "no",
|
||||
@@ -48,7 +48,7 @@ func (p *Post) SetToread() {
|
||||
}
|
||||
|
||||
func (p *Post) AddValues(values url.Values) {
|
||||
values.Add("url", p.Url)
|
||||
values.Add("url", p.URL)
|
||||
values.Add("description", p.Description)
|
||||
values.Add("tags", p.Tags)
|
||||
if p.Toread != "" {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -31,7 +33,7 @@ type Client struct {
|
||||
priority int
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
type message struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
|
||||
@@ -44,14 +46,14 @@ type Message struct {
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
type errorResponse struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
func NewClient(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = defaultPushoverURL
|
||||
}
|
||||
@@ -76,7 +78,7 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
return errors.New("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
msg := &message{
|
||||
User: c.user,
|
||||
Token: c.token,
|
||||
Device: c.device,
|
||||
@@ -94,45 +96,45 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
)
|
||||
|
||||
if err := c.makeRequest(msg); err != nil {
|
||||
return fmt.Errorf("c.makeRequest: %w", err)
|
||||
return fmt.Errorf("pushover: unable to send message: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload *Message) error {
|
||||
func (c *Client) makeRequest(payload *message) error {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
return fmt.Errorf("pushover: unable to encode request body: %w", err)
|
||||
}
|
||||
url := c.prefix + "/1/messages.json"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest: %w", err)
|
||||
return fmt.Errorf("pushover: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpClient.Do: %w", err)
|
||||
return fmt.Errorf("pushover: unable to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
errorMessage := resp.Status
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
var errResp errorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil {
|
||||
if len(errResp.Errors) > 0 {
|
||||
errorMessage = strings.Join(errResp.Errors, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
|
||||
return fmt.Errorf("pushover: API error: status=%d %s", resp.StatusCode, errorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -53,7 +55,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
@@ -70,7 +72,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Collection collection `json:"collection"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -48,7 +50,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
var request *http.Request
|
||||
if c.onlyURL {
|
||||
requestBodyJson, err := json.Marshal(&readeckBookmark{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Labels: labelsSplit,
|
||||
})
|
||||
@@ -91,7 +93,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
}
|
||||
|
||||
contentBodyHeader, err := json.Marshal(&partContentHeader{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -120,7 +122,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to send request: %v", err)
|
||||
@@ -135,7 +137,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
}
|
||||
|
||||
type readeckBookmark struct {
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
@@ -145,6 +147,6 @@ type contentHeader struct {
|
||||
}
|
||||
|
||||
type partContentHeader struct {
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
ContentHeader contentHeader `json:"headers"`
|
||||
}
|
||||
|
||||
@@ -4,257 +4,279 @@
|
||||
package readeck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
entryURL := "https://example.com/article"
|
||||
entryTitle := "Example Title"
|
||||
entryContent := "<p>Some HTML content</p>"
|
||||
labels := "tag1,tag2"
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyURL bool
|
||||
baseURL string
|
||||
apiKey string
|
||||
labels string
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation with only URL",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", ct)
|
||||
}
|
||||
entryURL := "https://example.com/article"
|
||||
entryTitle := "Example Title"
|
||||
entryContent := "<p>Some HTML content</p>"
|
||||
labels := "tag1,tag2"
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("failed to parse JSON body: %v", err)
|
||||
}
|
||||
if u := payload["url"]; u != entryURL {
|
||||
t.Errorf("expected url %s, got %v", entryURL, u)
|
||||
}
|
||||
if title := payload["title"]; title != entryTitle {
|
||||
t.Errorf("expected title %s, got %v", entryTitle, title)
|
||||
}
|
||||
// Labels should be split into an array
|
||||
if raw := payload["labels"]; raw == nil {
|
||||
t.Errorf("expected labels to be set")
|
||||
} else if arr, ok := raw.([]any); ok {
|
||||
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", arr)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("labels should be an array, got %T", raw)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful bookmark creation with content (multipart)",
|
||||
onlyURL: false,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "multipart/form-data;") {
|
||||
t.Errorf("expected multipart/form-data, got %s", ct)
|
||||
}
|
||||
boundaryIdx := strings.Index(ct, "boundary=")
|
||||
if boundaryIdx == -1 {
|
||||
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
|
||||
}
|
||||
boundary := ct[boundaryIdx+len("boundary="):]
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyURL bool
|
||||
baseURL string
|
||||
apiKey string
|
||||
labels string
|
||||
entryURL string
|
||||
entryTitle string
|
||||
entryContent string
|
||||
serverResponse func(w http.ResponseWriter, r *http.Request)
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "successful bookmark creation with only URL",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", ct)
|
||||
}
|
||||
|
||||
seenLabels := []string{}
|
||||
var seenURL, seenTitle, seenFeature string
|
||||
var resourceHeader map[string]any
|
||||
var resourceBody string
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("failed to parse JSON body: %v", err)
|
||||
}
|
||||
if u := payload["url"]; u != entryURL {
|
||||
t.Errorf("expected url %s, got %v", entryURL, u)
|
||||
}
|
||||
if title := payload["title"]; title != entryTitle {
|
||||
t.Errorf("expected title %s, got %v", entryTitle, title)
|
||||
}
|
||||
// Labels should be split into an array
|
||||
if raw := payload["labels"]; raw == nil {
|
||||
t.Errorf("expected labels to be set")
|
||||
} else if arr, ok := raw.([]any); ok {
|
||||
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", arr)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("labels should be an array, got %T", raw)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful bookmark creation with content (multipart)",
|
||||
onlyURL: false,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/bookmarks/" {
|
||||
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("expected Authorization Bearer header, got %q", got)
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "multipart/form-data;") {
|
||||
t.Errorf("expected multipart/form-data, got %s", ct)
|
||||
}
|
||||
_, after, ok := strings.Cut(ct, "boundary=")
|
||||
if !ok {
|
||||
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
|
||||
}
|
||||
boundary := after
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reading multipart: %v", err)
|
||||
}
|
||||
name := part.FormName()
|
||||
data, _ := io.ReadAll(part)
|
||||
switch name {
|
||||
case "url":
|
||||
seenURL = string(data)
|
||||
case "title":
|
||||
seenTitle = string(data)
|
||||
case "feature_find_main":
|
||||
seenFeature = string(data)
|
||||
case "labels":
|
||||
seenLabels = append(seenLabels, string(data))
|
||||
case "resource":
|
||||
// First line is JSON header, then newline, then content
|
||||
all := string(data)
|
||||
idx := strings.IndexByte(all, '\n')
|
||||
if idx == -1 {
|
||||
t.Fatalf("resource content missing header separator")
|
||||
}
|
||||
headerJSON := all[:idx]
|
||||
resourceBody = all[idx+1:]
|
||||
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
|
||||
t.Fatalf("invalid resource header JSON: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seenLabels := []string{}
|
||||
var seenURL, seenTitle, seenFeature string
|
||||
var resourceHeader map[string]any
|
||||
var resourceBody string
|
||||
|
||||
if seenURL != entryURL {
|
||||
t.Errorf("expected url %s, got %s", entryURL, seenURL)
|
||||
}
|
||||
if seenTitle != entryTitle {
|
||||
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
|
||||
}
|
||||
if seenFeature != "false" {
|
||||
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
|
||||
}
|
||||
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", seenLabels)
|
||||
}
|
||||
if resourceHeader == nil {
|
||||
t.Fatalf("missing resource header")
|
||||
}
|
||||
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
|
||||
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
|
||||
}
|
||||
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
|
||||
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("missing resource header 'headers' field")
|
||||
}
|
||||
if resourceBody != entryContent {
|
||||
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
|
||||
}
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reading multipart: %v", err)
|
||||
}
|
||||
name := part.FormName()
|
||||
data, _ := io.ReadAll(part)
|
||||
switch name {
|
||||
case "url":
|
||||
seenURL = string(data)
|
||||
case "title":
|
||||
seenTitle = string(data)
|
||||
case "feature_find_main":
|
||||
seenFeature = string(data)
|
||||
case "labels":
|
||||
seenLabels = append(seenLabels, string(data))
|
||||
case "resource":
|
||||
// First line is JSON header, then newline, then content
|
||||
all := string(data)
|
||||
before, after, ok := strings.Cut(all, "\n")
|
||||
if !ok {
|
||||
t.Fatalf("resource content missing header separator")
|
||||
}
|
||||
headerJSON := before
|
||||
resourceBody = after
|
||||
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
|
||||
t.Fatalf("invalid resource header JSON: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error when server returns 400",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create bookmark",
|
||||
},
|
||||
{
|
||||
name: "error when missing baseURL or apiKey",
|
||||
onlyURL: true,
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: nil,
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
}
|
||||
if seenURL != entryURL {
|
||||
t.Errorf("expected url %s, got %s", entryURL, seenURL)
|
||||
}
|
||||
if seenTitle != entryTitle {
|
||||
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
|
||||
}
|
||||
if seenFeature != "false" {
|
||||
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
|
||||
}
|
||||
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
|
||||
t.Errorf("unexpected labels: %#v", seenLabels)
|
||||
}
|
||||
if resourceHeader == nil {
|
||||
t.Fatalf("missing resource header")
|
||||
}
|
||||
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
|
||||
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
|
||||
}
|
||||
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
|
||||
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("missing resource header 'headers' field")
|
||||
}
|
||||
if resourceBody != entryContent {
|
||||
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer srv.Close()
|
||||
serverURL = srv.URL
|
||||
}
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = serverURL
|
||||
}
|
||||
apiKey := tt.apiKey
|
||||
if apiKey == "" {
|
||||
apiKey = "test-api-key"
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error when server returns 400",
|
||||
onlyURL: true,
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unable to create bookmark",
|
||||
},
|
||||
{
|
||||
name: "error when missing baseURL or apiKey",
|
||||
onlyURL: true,
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
labels: labels,
|
||||
entryURL: entryURL,
|
||||
entryTitle: entryTitle,
|
||||
entryContent: entryContent,
|
||||
serverResponse: nil,
|
||||
wantErr: true,
|
||||
errContains: "missing base URL or API key",
|
||||
},
|
||||
}
|
||||
|
||||
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var serverURL string
|
||||
if tt.serverResponse != nil {
|
||||
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
|
||||
defer srv.Close()
|
||||
serverURL = srv.URL
|
||||
}
|
||||
baseURL := tt.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = serverURL
|
||||
}
|
||||
apiKey := tt.apiKey
|
||||
if apiKey == "" {
|
||||
apiKey = "test-api-key"
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
|
||||
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
baseURL := "https://readeck.example.com"
|
||||
apiKey := "key"
|
||||
labels := "tag1,tag2"
|
||||
onlyURL := true
|
||||
baseURL := "https://readeck.example.com"
|
||||
apiKey := "key"
|
||||
labels := "tag1,tag2"
|
||||
onlyURL := true
|
||||
|
||||
c := NewClient(baseURL, apiKey, labels, onlyURL)
|
||||
if c.baseURL != baseURL {
|
||||
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
|
||||
}
|
||||
if c.apiKey != apiKey {
|
||||
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
|
||||
}
|
||||
if c.labels != labels {
|
||||
t.Errorf("expected labels %s, got %s", labels, c.labels)
|
||||
}
|
||||
if c.onlyURL != onlyURL {
|
||||
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
|
||||
}
|
||||
c := NewClient(baseURL, apiKey, labels, onlyURL)
|
||||
if c.baseURL != baseURL {
|
||||
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
|
||||
}
|
||||
if c.apiKey != apiKey {
|
||||
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
|
||||
}
|
||||
if c.labels != labels {
|
||||
t.Errorf("expected labels %s, got %s", labels, c.labels)
|
||||
}
|
||||
if c.onlyURL != onlyURL {
|
||||
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ func (c *Client) CreateDocument(entryURL string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readwise: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
@@ -27,7 +31,7 @@ type BridgeMeta struct {
|
||||
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
|
||||
endpointURL, err := url.Parse(rssBridgeURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to parse bridge URL: %w", err)
|
||||
}
|
||||
|
||||
values := endpointURL.Query()
|
||||
@@ -43,14 +47,16 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to execute request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -58,13 +64,13 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if response.StatusCode > 400 {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
|
||||
if response.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("rssbridge: unexpected status code %d", response.StatusCode)
|
||||
}
|
||||
|
||||
var bridgeResponse []*Bridge
|
||||
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
|
||||
return nil, fmt.Errorf("rssbridge: unable to decode bridge response: %w", err)
|
||||
}
|
||||
|
||||
for _, bridge := range bridgeResponse {
|
||||
@@ -76,7 +82,7 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
|
||||
if strings.HasPrefix(bridge.URL, "./") {
|
||||
bridge.URL = rssBridgeURL + bridge.URL[2:]
|
||||
|
||||
slog.Debug("Rewrited relative RSS bridge URL",
|
||||
slog.Debug("Rewrote relative RSS bridge URL",
|
||||
slog.String("name", bridge.BridgeMeta.Name),
|
||||
slog.String("url", bridge.URL),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -59,7 +61,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shaarli: unable to send request: %v", err)
|
||||
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
|
||||
func (c *Client) generateBearerToken() string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
|
||||
payload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"iat":%d}`, time.Now().Unix()))
|
||||
data := header + "." + payload
|
||||
|
||||
mac := hmac.New(sha512.New, []byte(c.apiSecret))
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -65,7 +67,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
@@ -80,7 +82,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticate() (token string, err error) {
|
||||
func (c *Client) authenticate() (string, error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
@@ -100,7 +102,7 @@ func (c *Client) authenticate() (token string, err error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -81,12 +83,12 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -50,7 +51,7 @@ func (c *Client) GetMe() (*User, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
|
||||
@@ -90,7 +91,7 @@ func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: unable to send request: %v", err)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -30,7 +32,15 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
|
||||
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
username: username,
|
||||
password: password,
|
||||
tags: tags,
|
||||
onlyURL: onlyURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
@@ -49,7 +59,7 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
|
||||
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
|
||||
return fmt.Errorf("wallabag: unable to generate entries endpoint: %v", err)
|
||||
}
|
||||
|
||||
if c.onlyURL {
|
||||
@@ -63,12 +73,12 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
|
||||
return fmt.Errorf("wallabag: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallbag: unable to create request: %v", err)
|
||||
return fmt.Errorf("wallabag: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
@@ -76,7 +86,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallabag: unable to send request: %v", err)
|
||||
@@ -100,19 +110,19 @@ func (c *Client) getAccessToken() (string, error) {
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
|
||||
return "", fmt.Errorf("wallabag: unable to generate token endpoint: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallbag: unable to create request: %v", err)
|
||||
return "", fmt.Errorf("wallabag: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateEntry(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
entryURL := "https://example.com"
|
||||
entryTitle := "title"
|
||||
entryContent := "content"
|
||||
@@ -319,3 +323,21 @@ func TestNewClient(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -132,7 +134,7 @@ func (c *Client) makeRequest(eventType string, payload any) error {
|
||||
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
|
||||
request.Header.Set("X-Miniflux-Event-Type", eventType)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: unable to send request: %v", err)
|
||||
|
||||
@@ -99,12 +99,14 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"ar_SA": 6,
|
||||
"de_DE": 2,
|
||||
"el_EL": 2,
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fi_FI": 2,
|
||||
"fr_FR": 2,
|
||||
"gl_ES": 2,
|
||||
"hi_IN": 2,
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
|
||||
@@ -5,12 +5,14 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"ar_SA": "العربية",
|
||||
"de_DE": "Deutsch",
|
||||
"el_EL": "Ελληνικά",
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fi_FI": "Suomi",
|
||||
"fr_FR": "Français",
|
||||
"gl_ES": "Galego",
|
||||
"hi_IN": "हिन्दी",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
|
||||
@@ -31,6 +31,11 @@ func getPluralForm(lang string, n int) int {
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
case "gl_ES":
|
||||
if n != 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "id_ID", "ja_JP":
|
||||
return 0
|
||||
case "pl_PL":
|
||||
|
||||
@@ -44,6 +44,13 @@ func TestPluralRules(t *testing.T) {
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Galician (gl_ES) - n != 1
|
||||
"gl_ES": {
|
||||
0: 1, // n != 1
|
||||
1: 0, // n == 1
|
||||
2: 1, // n != 1
|
||||
5: 1, // n != 1
|
||||
},
|
||||
// Indonesian (id_ID) - always form 0
|
||||
"id_ID": {
|
||||
0: 0,
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"action.cancel": "إلغاء",
|
||||
"action.download": "تحميل",
|
||||
"action.edit": "تعديل",
|
||||
"action.home_screen": "إضافة إلى الشاشة الرئيسية",
|
||||
"action.import": "استيراد",
|
||||
"action.login": "تسجيل الدخول",
|
||||
"action.or": "أو",
|
||||
"action.remove": "حذف",
|
||||
"action.remove_feed": "حذف هذا المصدر",
|
||||
"action.save": "حفظ",
|
||||
"action.subscribe": "اشتراك",
|
||||
"action.update": "تحديث",
|
||||
"alert.account_linked": "تم ربط حسابك الخارجي!",
|
||||
"alert.account_unlinked": "تم فك ارتباط حسابك الخارجي!",
|
||||
"alert.background_feed_refresh": "يتم تحديث جميع المصادر في الخلفية. يمكنك الاستمرار في استخدام Miniflux أثناء تشغيل هذه العملية.",
|
||||
"alert.feed_error": "توجد مشكلة في هذا المصدر",
|
||||
"alert.no_starred": "لا توجد في المُفضلة.",
|
||||
"alert.no_category": "لا توجد فئة.",
|
||||
"alert.no_category_entry": "لا توجد مقالات في هذه الفئة.",
|
||||
"alert.no_feed": "ليس لديك أي مصادر.",
|
||||
"alert.no_feed_entry": "لا توجد مقالات لهذا المصدر.",
|
||||
"alert.no_feed_in_category": "لا يوجد مصدر لهذه الفئة.",
|
||||
"alert.no_history": "لا يوجد سجل في الوقت الحالي.",
|
||||
"alert.no_search_result": "لا توجد نتائج لهذا البحث.",
|
||||
"alert.no_shared_entry": "لا توجد مشاركات.",
|
||||
"alert.no_tag_entry": "لا توجد مقالات تطابق هذا الوسم.",
|
||||
"alert.no_unread_entry": "لا توجد مقالات غير مقروءة.",
|
||||
"alert.no_user": "أنت المستخدم الوحيد.",
|
||||
"alert.prefs_saved": "تم حفظ التفضيلات!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقة واحدة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقتين قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقائق قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.",
|
||||
"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى."
|
||||
],
|
||||
"confirm.loading": "جارٍ التحميل...",
|
||||
"confirm.no": "لا",
|
||||
"confirm.question": "هل أنت متأكد؟",
|
||||
"confirm.question.refresh": "هل أنت متأكد أنك تريد فرض التحديث؟",
|
||||
"confirm.yes": "نعم",
|
||||
"enclosure_media_controls.seek": "بحث:",
|
||||
"enclosure_media_controls.seek.title": "بحث %s ثانية",
|
||||
"enclosure_media_controls.speed": "السرعة:",
|
||||
"enclosure_media_controls.speed.faster": "أسرع",
|
||||
"enclosure_media_controls.speed.faster.title": "أسرع بـ %sx",
|
||||
"enclosure_media_controls.speed.reset": "إعادة تعيين",
|
||||
"enclosure_media_controls.speed.reset.title": "إعادة تعيين السرعة إلى 1x",
|
||||
"enclosure_media_controls.speed.slower": "أبطأ",
|
||||
"enclosure_media_controls.speed.slower.title": "أبطأ بـ %sx",
|
||||
"entry.starred.toast.off": "أزيلت من المفضلة",
|
||||
"entry.starred.toast.on": "أضيفت للمفضلة",
|
||||
"entry.starred.toggle.off": "إزالة من المفضلة",
|
||||
"entry.starred.toggle.on": "إضافة للمفضلة",
|
||||
"entry.comments.label": "تعليقات",
|
||||
"entry.comments.title": "عرض التعليقات",
|
||||
"entry.estimated_reading_time": [
|
||||
"قراءة %d دقيقة",
|
||||
"قراءة دقيقة واحدة",
|
||||
"قراءة دقيقتين",
|
||||
"قراءة %d دقائق",
|
||||
"قراءة %d دقيقة",
|
||||
"قراءة %d دقيقة"
|
||||
],
|
||||
"entry.external_link.label": "رابط خارجي",
|
||||
"entry.save.completed": "تم!",
|
||||
"entry.save.label": "حفظ",
|
||||
"entry.save.title": "حفظ هذا المقال",
|
||||
"entry.save.toast.completed": "تم حفظ المقال",
|
||||
"entry.scraper.completed": "تم!",
|
||||
"entry.scraper.label": "تحميل",
|
||||
"entry.scraper.title": "جلب المحتوى الأصلي",
|
||||
"entry.share.label": "مشاركة",
|
||||
"entry.share.title": "مشاركة هذا المقال",
|
||||
"entry.shared_entry.label": "مشاركة",
|
||||
"entry.shared_entry.title": "فتح الرابط العام",
|
||||
"entry.state.loading": "جارٍ التحميل...",
|
||||
"entry.state.saving": "جارٍ الحفظ...",
|
||||
"entry.status.mark_as_read": "تحديد كمقروء",
|
||||
"entry.status.mark_as_unread": "تحديد كغير مقروء",
|
||||
"entry.status.title": "تغيير حالة المقال",
|
||||
"entry.status.toast.read": "تم تحديده كمقروء",
|
||||
"entry.status.toast.unread": "تم تحديده كغير مقروء",
|
||||
"entry.tags.label": "الوسوم:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"إظهار %d وسم",
|
||||
"إظهار وسم واحد",
|
||||
"إظهار وسمين",
|
||||
"إظهار %d وسوم",
|
||||
"إظهار %d وسماً",
|
||||
"إظهار %d وسماً"
|
||||
],
|
||||
"entry.unshare.label": "إلغاء المشاركة",
|
||||
"error.api_key_already_exists": "مفتاح API هذا موجود بالفعل.",
|
||||
"error.bad_credentials": "اسم المستخدم أو كلمة المرور غير صالحة.",
|
||||
"error.category_already_exists": "هذه الفئة موجودة بالفعل.",
|
||||
"error.category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
|
||||
"error.database_error": "خطأ في قاعدة البيانات: %v.",
|
||||
"error.different_passwords": "كلمات المرور غير متطابقة.",
|
||||
"error.duplicate_fever_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Fever!",
|
||||
"error.duplicate_googlereader_username": "يوجد بالفعل شخص آخر بنفس اسم مستخدم Google Reader!",
|
||||
"error.linktaco_missing_required_fields": "مطلوب رمز LinkTaco API و Organization Slug",
|
||||
"error.duplicate_linked_account": "يوجد بالفعل شخص مرتبط بهذا الموفر!",
|
||||
"error.duplicated_feed": "هذا المصدر موجود بالفعل.",
|
||||
"error.empty_file": "هذا الملف فارغ.",
|
||||
"error.entries_per_page_invalid": "عدد المقالات في الصفحة غير صالح.",
|
||||
"error.feed_already_exists": "هذا المصدر موجود بالفعل.",
|
||||
"error.feed_category_not_found": "هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.",
|
||||
"error.feed_format_not_detected": "تعذر اكتشاف تنسيق المصدر: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "قاعدة قائمة الحظر غير صالحة.",
|
||||
"error.feed_invalid_keeplist_rule": "قاعدة قائمة الاحتفاظ غير صالحة.",
|
||||
"error.feed_mandatory_fields": "الرابط والفئة إلزاميان.",
|
||||
"error.feed_not_found": "هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.",
|
||||
"error.feed_title_not_empty": "عنوان المصدر لا يمكن أن يكون فارغاً.",
|
||||
"error.feed_url_not_empty": "رابط المصدر لا يمكن أن يكون فارغاً.",
|
||||
"error.fields_mandatory": "جميع الحقول إلزامية.",
|
||||
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
|
||||
"error.http_client_error": "خطأ في عميل HTTP: %v.",
|
||||
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
|
||||
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
|
||||
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
|
||||
"error.http_gateway_timeout": "الموقع غير متاح حالياً بسبب انتهاء مهلة البوابة. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_internal_server_error": "الموقع غير متاح حالياً بسبب خطأ في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_not_authorized": "الوصول إلى هذا الموقع غير مصرح به. قد يكون اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||
"error.http_resource_not_found": "المورد المطلوب غير موجود. يرجى التحقق من الرابط.",
|
||||
"error.http_response_too_large": "استجابة HTTP كبيرة جداً. يمكنك زيادة حد حجم استجابة HTTP في الإعدادات العامة (يتطلب إعادة تشغيل الخادم).",
|
||||
"error.http_service_unavailable": "الموقع غير متاح حالياً بسبب خطأ داخلي في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_too_many_requests": "أنشأ Miniflux عدداً كبيراً جداً من الطلبات لهذا الموقع. يرجى المحاولة لاحقاً أو تغيير إعدادات التطبيق.",
|
||||
"error.http_unexpected_status_code": "الموقع غير متاح حالياً بسبب رمز حالة HTTP غير متوقع: %d. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.invalid_categories_sorting_order": "ترتيب فرز الفئات غير صالح.",
|
||||
"error.invalid_default_home_page": "الصفحة الرئيسية الافتراضية غير صالحة!",
|
||||
"error.invalid_display_mode": "وضع عرض تطبيق الويب غير صالح.",
|
||||
"error.invalid_entry_direction": "اتجاه المقال غير صالح.",
|
||||
"error.invalid_entry_order": "ترتيب المقال غير صالح.",
|
||||
"error.invalid_feed_proxy_url": "رابط الوكيل (Proxy) غير صالح.",
|
||||
"error.invalid_feed_url": "رابط المصدر غير صالح.",
|
||||
"error.invalid_gesture_nav": "تصفح الإيماءات غير صالح.",
|
||||
"error.invalid_language": "اللغة غير صالحة.",
|
||||
"error.invalid_site_url": "رابط الموقع غير صالح.",
|
||||
"error.invalid_theme": "السمة غير صالحة.",
|
||||
"error.invalid_timezone": "المنطقة الزمنية غير صالحة.",
|
||||
"error.network_operation": "Miniflux غير قادر على الوصول إلى هذا الموقع بسبب خطأ في الشبكة: %v.",
|
||||
"error.network_timeout": "هذا الموقع بطيء جداً وانتهى وقت الطلب: %v",
|
||||
"error.password_min_length": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.",
|
||||
"error.proxy_url_not_empty": "رابط الوكيل لا يمكن أن يكون فارغاً.",
|
||||
"error.settings_block_rule_fieldname_invalid": "قاعدة الحظر غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "قاعدة الحظر غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
|
||||
"error.settings_block_rule_regex_required": "قاعدة الحظر غير صالحة: لم يتم توفير نمط للقاعدة #%d",
|
||||
"error.settings_block_rule_separator_required": "قاعدة الحظر غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
|
||||
"error.settings_invalid_domain_list": "قائمة النطاقات غير صالحة. يرجى تقديم قائمة مفصولة بمسافات للنطاقات.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "قاعدة الاحتفاظ غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "قاعدة الاحتفاظ غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا",
|
||||
"error.settings_keep_rule_regex_required": "قاعدة الاحتفاظ غير صالحة: لم يتم توفير نمط للقاعدة #%d",
|
||||
"error.settings_keep_rule_separator_required": "قاعدة الاحتفاظ غير صالحة: يجب فصل نمط القاعدة #%d بـ '='",
|
||||
"error.settings_mandatory_fields": "حقول اسم المستخدم، السمة، اللغة، والمنطقة الزمنية إلزامية.",
|
||||
"error.settings_media_playback_rate_range": "سرعة التشغيل خارج النطاق",
|
||||
"error.settings_reading_speed_is_positive": "يجب أن تكون سرعة القراءة أرقاماً صحيحة موجبة.",
|
||||
"error.site_url_not_empty": "رابط الموقع لا يمكن أن يكون فارغاً.",
|
||||
"error.subscription_not_found": "تعذر العثور على أي مصدر.",
|
||||
"error.title_required": "العنوان إلزامي.",
|
||||
"error.tls_error": "خطأ TLS: %q. يمكنك تعطيل التحقق من TLS في إعدادات المصدر إذا كنت ترغب في ذلك.",
|
||||
"error.unable_to_create_api_key": "تعذر إنشاء مفتاح API هذا.",
|
||||
"error.unable_to_create_category": "تعذر إنشاء هذه الفئة.",
|
||||
"error.unable_to_create_user": "تعذر إنشاء هذا المستخدم.",
|
||||
"error.unable_to_detect_rssbridge": "تعذر اكتشاف المصدر باستخدام RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "تعذر تحليل هذا المصدر: %v.",
|
||||
"error.unable_to_update_category": "تعذر تحديث هذه الفئة.",
|
||||
"error.unable_to_update_feed": "تعذر تحديث هذا المصدر.",
|
||||
"error.unable_to_update_user": "تعذر تحديث هذا المستخدم.",
|
||||
"error.unlink_account_without_password": "يجب عليك تحديد كلمة مرور وإلا لن تتمكن من تسجيل الدخول مرة أخرى.",
|
||||
"error.user_already_exists": "هذا المستخدم موجود بالفعل.",
|
||||
"error.user_mandatory_fields": "اسم المستخدم إلزامي.",
|
||||
"form.api_key.label.description": "تسمية مفتاح API",
|
||||
"form.category.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
|
||||
"form.category.label.title": "العنوان",
|
||||
"form.feed.fieldset.general": "عام",
|
||||
"form.feed.fieldset.integration": "خدمات الطرف الثالث",
|
||||
"form.feed.fieldset.network_settings": "إعدادات الشبكة",
|
||||
"form.feed.fieldset.rules": "قواعد",
|
||||
"form.feed.label.allow_self_signed_certificates": "السماح بالشهادات الموقعة ذاتياً أو غير الصالحة",
|
||||
"form.feed.label.apprise_service_urls": "قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة",
|
||||
"form.feed.label.block_filter_entry_rules": "قواعد حظر المقالات",
|
||||
"form.feed.label.blocklist_rules": "مرشحات الحظر المعتمدة على Regex",
|
||||
"form.feed.label.category": "الفئة",
|
||||
"form.feed.label.cookie": "تعيين ملفات تعريف الارتباط (Cookies)",
|
||||
"form.feed.label.crawler": "جلب المحتوى الأصلي",
|
||||
"form.feed.label.ignore_entry_updates": "تجاهل تحديثات المقالات",
|
||||
"form.feed.label.description": "الوصف",
|
||||
"form.feed.label.disable_http2": "تعطيل HTTP/2 لتجنب التتبع",
|
||||
"form.feed.label.disabled": "لا تقم بتحديث هذا المصدر",
|
||||
"form.feed.label.feed_password": "كلمة مرور المصدر",
|
||||
"form.feed.label.feed_url": "رابط المصدر",
|
||||
"form.feed.label.feed_username": "اسم مستخدم المصدر",
|
||||
"form.feed.label.fetch_via_proxy": "استخدم الوكيل الذي تم تكوينه على مستوى التطبيق",
|
||||
"form.feed.label.hide_globally": "إخفاء المقالات من القائمة العامة غير المقروءة",
|
||||
"form.feed.label.ignore_http_cache": "تجاهل ذاكرة التخزين المؤقت لـ HTTP",
|
||||
"form.feed.label.keep_filter_entry_rules": "قواعد السماح للمقالات",
|
||||
"form.feed.label.keeplist_rules": "مرشحات الاحتفاظ المعتمدة على Regex",
|
||||
"form.feed.label.no_media_player": "بدون مشغل الوسائط (صوت / فيديو)",
|
||||
"form.feed.label.ntfy_activate": "إرسال المقالات إلى ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "أولوية Ntfy الافتراضية",
|
||||
"form.feed.label.ntfy_high_priority": "أولوية Ntfy عالية",
|
||||
"form.feed.label.ntfy_low_priority": "أولوية Ntfy منخفضة",
|
||||
"form.feed.label.ntfy_max_priority": "أقصى أولوية Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "أدنى أولوية Ntfy",
|
||||
"form.feed.label.ntfy_priority": "أولوية Ntfy",
|
||||
"form.feed.label.ntfy_topic": "موضوع Ntfy (اختياري)",
|
||||
"form.feed.label.proxy_url": "رابط الوكيل (Proxy)",
|
||||
"form.feed.label.pushover_activate": "إرسال المقالات إلى Pushover",
|
||||
"form.feed.label.pushover_default_priority": "الأولوية الافتراضية",
|
||||
"form.feed.label.pushover_high_priority": "أولوية عالية",
|
||||
"form.feed.label.pushover_low_priority": "أولوية منخفضة",
|
||||
"form.feed.label.pushover_max_priority": "أولوية قصوى",
|
||||
"form.feed.label.pushover_min_priority": "أولوية دنيا",
|
||||
"form.feed.label.pushover_priority": "أولوية رسالة Pushover",
|
||||
"form.feed.label.rewrite_rules": "قواعد إعادة كتابة المحتوى",
|
||||
"form.feed.label.scraper_rules": "قواعد الكاشط (Scraper)",
|
||||
"form.feed.label.site_url": "رابط الموقع",
|
||||
"form.feed.label.title": "العنوان",
|
||||
"form.feed.label.urlrewrite_rules": "قواعد إعادة كتابة الروابط",
|
||||
"form.feed.label.user_agent": "تجاوز وكيل المستخدم الافتراضي (User Agent)",
|
||||
"form.feed.label.webhook_url": "تجاوز رابط الويب هوك (Webhook)",
|
||||
"form.import.label.file": "ملف OPML",
|
||||
"form.import.label.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",
|
||||
"form.integration.betula_activate": "حفظ المقالات في Betula",
|
||||
"form.integration.betula_token": "رمز Betula",
|
||||
"form.integration.betula_url": "رابط خادم Betula",
|
||||
"form.integration.cubox_activate": "حفظ المقالات في Cubox",
|
||||
"form.integration.cubox_api_link": "رابط Cubox API",
|
||||
"form.integration.discord_activate": "إرسال المقالات إلى Discord",
|
||||
"form.integration.discord_webhook_link": "رابط Discord Webhook",
|
||||
"form.integration.espial_activate": "حفظ المقالات في Espial",
|
||||
"form.integration.espial_api_key": "مفتاح Espial API",
|
||||
"form.integration.espial_endpoint": "نقطة نهاية Espial API",
|
||||
"form.integration.espial_tags": "وسوم Espial",
|
||||
"form.integration.fever_activate": "تفعيل Fever API",
|
||||
"form.integration.fever_endpoint": "نقطة نهاية Fever API:",
|
||||
"form.integration.fever_password": "كلمة مرور Fever",
|
||||
"form.integration.fever_username": "اسم مستخدم Fever",
|
||||
"form.integration.googlereader_activate": "تفعيل Google Reader API",
|
||||
"form.integration.googlereader_endpoint": "نقطة نهاية Google Reader API:",
|
||||
"form.integration.googlereader_password": "كلمة مرور Google Reader",
|
||||
"form.integration.googlereader_username": "اسم مستخدم Google Reader",
|
||||
"form.integration.instapaper_activate": "حفظ المقالات في Instapaper",
|
||||
"form.integration.instapaper_password": "كلمة مرور Instapaper",
|
||||
"form.integration.instapaper_username": "اسم مستخدم Instapaper",
|
||||
"form.integration.karakeep_activate": "حفظ المقالات في Karakeep",
|
||||
"form.integration.karakeep_api_key": "مفتاح Karakeep API",
|
||||
"form.integration.karakeep_url": "نقطة نهاية Karakeep API",
|
||||
"form.integration.karakeep_tags": "وسوم Karakeep",
|
||||
"form.integration.linkace_activate": "حفظ المقالات في LinkAce",
|
||||
"form.integration.linkace_api_key": "مفتاح LinkAce API",
|
||||
"form.integration.linkace_check_disabled": "تعطيل فحص الرابط",
|
||||
"form.integration.linkace_endpoint": "نقطة نهاية LinkAce API",
|
||||
"form.integration.linkace_is_private": "تحديد الرابط كخاص",
|
||||
"form.integration.linkace_tags": "وسوم LinkAce",
|
||||
"form.integration.linkding_activate": "حفظ المقالات في Linkding",
|
||||
"form.integration.linkding_api_key": "مفتاح Linkding API",
|
||||
"form.integration.linkding_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
|
||||
"form.integration.linkding_endpoint": "نقطة نهاية Linkding API",
|
||||
"form.integration.linkding_tags": "وسوم Linkding",
|
||||
"form.integration.linktaco_activate": "حفظ المقالات في LinkTaco",
|
||||
"form.integration.linktaco_api_token": "رمز LinkTaco API",
|
||||
"form.integration.linktaco_api_token_hint": "احصل على رمز الوصول الشخصي الخاص بك في",
|
||||
"form.integration.linktaco_org_slug": "Slug المؤسسة",
|
||||
"form.integration.linktaco_tags": "الوسوم (بحد أقصى 10، مفصولة بفواصل)",
|
||||
"form.integration.linktaco_tags_hint": "حد أقصى 10 وسوم، مفصولة بفواصل",
|
||||
"form.integration.linktaco_visibility": "الرؤية",
|
||||
"form.integration.linktaco_visibility_public": "عام",
|
||||
"form.integration.linktaco_visibility_private": "خاص",
|
||||
"form.integration.linktaco_visibility_hint": "تتطلب رؤية خاصة حساب LinkTaco مدفوع",
|
||||
"form.integration.linkwarden_activate": "حفظ المقالات في Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "مفتاح Linkwarden API",
|
||||
"form.integration.linkwarden_endpoint": "رابط Linkwarden الأساسي",
|
||||
"form.integration.linkwarden_collection_id": "معرف مجموعة Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "إرسال مقالات جديدة إلى Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "معرف غرفة Matrix",
|
||||
"form.integration.matrix_bot_password": "كلمة مرور مستخدم Matrix",
|
||||
"form.integration.matrix_bot_url": "رابط خادم Matrix",
|
||||
"form.integration.matrix_bot_user": "اسم المستخدم في Matrix",
|
||||
"form.integration.notion_activate": "حفظ المقالات في Notion",
|
||||
"form.integration.notion_page_id": "معرف صفحة Notion",
|
||||
"form.integration.notion_token": "رمز Notion السري",
|
||||
"form.integration.ntfy_activate": "إرسال المقالات إلى ntfy",
|
||||
"form.integration.ntfy_api_token": "رمز Ntfy API (اختياري)",
|
||||
"form.integration.ntfy_icon_url": "رابط أيقونة Ntfy (اختياري)",
|
||||
"form.integration.ntfy_internal_links": "استخدام روابط داخلية عند النقر (اختياري)",
|
||||
"form.integration.ntfy_password": "كلمة مرور Ntfy (اختياري)",
|
||||
"form.integration.ntfy_topic": "موضوع Ntfy (يستخدم الافتراضي إذا لم يُحدد في المصدر)",
|
||||
"form.integration.ntfy_url": "رابط Ntfy (اختياري، الافتراضي هو ntfy.sh)",
|
||||
"form.integration.ntfy_username": "اسم مستخدم Ntfy (اختياري)",
|
||||
"form.integration.nunux_keeper_activate": "حفظ المقالات في Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "مفتاح Nunux Keeper API",
|
||||
"form.integration.nunux_keeper_endpoint": "نقطة نهاية Nunux Keeper API",
|
||||
"form.integration.omnivore_activate": "حفظ المقالات في Omnivore",
|
||||
"form.integration.omnivore_api_key": "مفتاح Omnivore API",
|
||||
"form.integration.omnivore_url": "نقطة نهاية Omnivore API",
|
||||
"form.integration.pinboard_activate": "حفظ المقالات في Pinboard",
|
||||
"form.integration.pinboard_bookmark": "تحديد الإشارة المرجعية كغير مقروءة",
|
||||
"form.integration.pinboard_tags": "وسوم Pinboard",
|
||||
"form.integration.pinboard_token": "رمز Pinboard API",
|
||||
"form.integration.pushover_activate": "إرسال المقالات إلى Pushover",
|
||||
"form.integration.pushover_device": "جهاز Pushover (اختياري)",
|
||||
"form.integration.pushover_prefix": "بادئة رابط Pushover (اختياري)",
|
||||
"form.integration.pushover_token": "رمز API لتطبيق Pushover",
|
||||
"form.integration.pushover_user": "مفتاح مستخدم Pushover",
|
||||
"form.integration.raindrop_activate": "حفظ المقالات في Raindrop",
|
||||
"form.integration.raindrop_collection_id": "معرف المجموعة",
|
||||
"form.integration.raindrop_tags": "الوسوم (مفصولة بفواصل)",
|
||||
"form.integration.raindrop_token": "رمز (تجريبي)",
|
||||
"form.integration.readeck_activate": "حفظ المقالات في readeck",
|
||||
"form.integration.readeck_api_key": "مفتاح Readeck API",
|
||||
"form.integration.readeck_endpoint": "رابط Readeck",
|
||||
"form.integration.readeck_labels": "تسميات Readeck",
|
||||
"form.integration.readeck_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
|
||||
"form.integration.readeck_push_activate": "إرسال المقالات الجديدة تلقائياً إلى Readeck",
|
||||
"form.integration.readwise_activate": "حفظ المقالات في Readwise Reader",
|
||||
"form.integration.readwise_api_key": "رمز الوصول لـ Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "احصل على رمز الوصول لـ Readwise الخاص بك",
|
||||
"form.integration.rssbridge_activate": "تحقق من RSS-Bridge عند إضافة الاشتراكات",
|
||||
"form.integration.rssbridge_token": "رمز مصادقة RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "رابط خادم RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "حفظ المقالات في Shaarli",
|
||||
"form.integration.shaarli_api_secret": "سر Shaarli API",
|
||||
"form.integration.shaarli_endpoint": "رابط Shaarli",
|
||||
"form.integration.shiori_activate": "حفظ المقالات في Shiori",
|
||||
"form.integration.shiori_endpoint": "نقطة نهاية Shiori API",
|
||||
"form.integration.shiori_password": "كلمة مرور Shiori",
|
||||
"form.integration.shiori_username": "اسم مستخدم Shiori",
|
||||
"form.integration.slack_activate": "إرسال المقالات إلى Slack",
|
||||
"form.integration.slack_webhook_link": "رابط Slack Webhook",
|
||||
"form.integration.telegram_bot_activate": "إرسال المقالات الجديدة إلى دردشة Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "تعطيل الأزرار",
|
||||
"form.integration.telegram_bot_disable_notification": "تعطيل الإشعارات",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "تعطيل معاينة الرابط",
|
||||
"form.integration.telegram_bot_token": "رمز البوت (Token)",
|
||||
"form.integration.telegram_chat_id": "معرف الدردشة",
|
||||
"form.integration.telegram_topic_id": "معرف الموضوع (Topic ID)",
|
||||
"form.integration.wallabag_activate": "حفظ المقالات في Wallabag",
|
||||
"form.integration.wallabag_client_id": "معرف عميل Wallabag",
|
||||
"form.integration.wallabag_client_secret": "سر عميل Wallabag",
|
||||
"form.integration.wallabag_endpoint": "رابط Wallabag الأساسي",
|
||||
"form.integration.wallabag_only_url": "إرسال الرابط فقط (بدلاً من المحتوى الكامل)",
|
||||
"form.integration.wallabag_password": "كلمة مرور Wallabag",
|
||||
"form.integration.wallabag_username": "اسم مستخدم Wallabag",
|
||||
"form.integration.wallabag_tags": "وسوم Wallabag",
|
||||
"form.integration.webhook_activate": "تفعيل Webhooks",
|
||||
"form.integration.webhook_secret": "سر Webhooks",
|
||||
"form.integration.webhook_url": "رابط Webhook الافتراضي",
|
||||
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
|
||||
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
|
||||
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
|
||||
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
|
||||
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "قراءة المقالات عن طريق فتح الروابط الخارجية",
|
||||
"form.prefs.label.categories_sorting_order": "فرز الفئات",
|
||||
"form.prefs.label.cjk_reading_speed": "سرعة القراءة للغات الصينية والكورية واليابانية (حرف في الدقيقة)",
|
||||
"form.prefs.label.custom_css": "CSS مخصص",
|
||||
"form.prefs.label.custom_js": "JavaScript مخصص",
|
||||
"form.prefs.label.default_home_page": "الصفحة الرئيسية الافتراضية",
|
||||
"form.prefs.label.default_reading_speed": "سرعة القراءة للغات الأخرى (كلمة في الدقيقة)",
|
||||
"form.prefs.label.display_mode": "وضع العرض (Progressive Web App - PWA)",
|
||||
"form.prefs.label.entries_per_page": "عدد المقالات في الصفحة",
|
||||
"form.prefs.label.entry_order": "عمود فرز المقالات",
|
||||
"form.prefs.label.entry_sorting": "فرز المقالات",
|
||||
"form.prefs.label.entry_swipe": "تفعيل التمرير للمقالات على الشاشات التي تعمل باللمس",
|
||||
"form.prefs.label.external_font_hosts": "مضيفو الخطوط الخارجية",
|
||||
"form.prefs.label.gesture_nav": "إيماءة للتنقل بين المقالات",
|
||||
"form.prefs.label.keyboard_shortcuts": "تفعيل اختصارات لوحة المفاتيح",
|
||||
"form.prefs.label.language": "اللغة",
|
||||
"form.prefs.label.mark_read_manually": "تحديد المقالات كمقروءة يدوياً",
|
||||
"form.prefs.label.mark_read_on_media_completion": "حدد كمقروء فقط عندما يصل تشغيل الصوت/الفيديو إلى 90% من الاكتمال",
|
||||
"form.prefs.label.mark_read_on_view": "تحديد المقالات تلقائياً كمقروءة عند عرضها",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "حدد المقالات كمقروءة عند عرضها. بالنسبة للصوت/الفيديو، حدد كمقروء عند اكتمال 90%",
|
||||
"form.prefs.label.media_playback_rate": "سرعة تشغيل الصوت/فيديو",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "فتح الروابط الخارجية في تبويب جديد (يضيف target=\"_blank\" للروابط)",
|
||||
"form.prefs.label.show_reading_time": "إظهار الوقت المقدر للقراءة للمقالات",
|
||||
"form.prefs.label.theme": "السمة",
|
||||
"form.prefs.label.timezone": "المنطقة الزمنية",
|
||||
"form.prefs.select.alphabetical": "أبجدي",
|
||||
"form.prefs.select.browser": "المتصفح",
|
||||
"form.prefs.select.created_time": "وقت إنشاء المقال",
|
||||
"form.prefs.select.fullscreen": "ملء الشاشة",
|
||||
"form.prefs.select.minimal_ui": "الحد الأدنى",
|
||||
"form.prefs.select.none": "بدون",
|
||||
"form.prefs.select.older_first": "المقالات القديمة أولاً",
|
||||
"form.prefs.select.publish_time": "وقت نشر المقال",
|
||||
"form.prefs.select.recent_first": "المقالات الحديثة أولاً",
|
||||
"form.prefs.select.standalone": "مستقل",
|
||||
"form.prefs.select.swipe": "تمرير سريع",
|
||||
"form.prefs.select.tap": "نقر مزدوج",
|
||||
"form.prefs.select.unread_count": "عدد غير المقروءة",
|
||||
"form.submit.loading": "جارٍ التحميل...",
|
||||
"form.submit.saving": "جارٍ الحفظ...",
|
||||
"form.user.label.admin": "مدير",
|
||||
"form.user.label.confirmation": "تأكيد كلمة المرور",
|
||||
"form.user.label.password": "كلمة المرور",
|
||||
"form.user.label.username": "اسم المستخدم",
|
||||
"menu.about": "حول",
|
||||
"menu.add_feed": "إضافة مصدر",
|
||||
"menu.add_user": "إضافة مستخدم",
|
||||
"menu.api_keys": "مفاتيح API",
|
||||
"menu.categories": "الفئات",
|
||||
"menu.create_api_key": "إنشاء مفتاح API جديد",
|
||||
"menu.create_category": "إنشاء فئة",
|
||||
"menu.edit_category": "تعديل",
|
||||
"menu.edit_feed": "تعديل",
|
||||
"menu.export": "تصدير",
|
||||
"menu.feed_entries": "المقالات",
|
||||
"menu.feeds": "المصادر",
|
||||
"menu.flush_history": "مسح السجل",
|
||||
"menu.history": "السجل",
|
||||
"menu.home_page": "الصفحة الرئيسية",
|
||||
"menu.import": "استيراد",
|
||||
"menu.integrations": "خدمات مرتبطة",
|
||||
"menu.logout": "تسجيل الخروج",
|
||||
"menu.mark_all_as_read": "تحديد الكل كمقروء",
|
||||
"menu.mark_page_as_read": "تحديد هذه الصفحة كمقروءة",
|
||||
"menu.preferences": "التفضيلات",
|
||||
"menu.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
|
||||
"menu.refresh_feed": "تحديث",
|
||||
"menu.search": "بحث",
|
||||
"menu.sessions": "الجلسات",
|
||||
"menu.settings": "الإعدادات",
|
||||
"menu.shared_entries": "المقالات المشاركة",
|
||||
"menu.show_all_entries": "إظهار كل المقالات",
|
||||
"menu.show_only_starred_entries": "إظهار المقالات المفضلة فقط",
|
||||
"menu.show_only_unread_entries": "إظهار المقالات غير المقروءة فقط",
|
||||
"menu.starred": "المفضلة",
|
||||
"menu.title": "القائمة",
|
||||
"menu.unread": "غير مقروء",
|
||||
"menu.users": "المستخدمون",
|
||||
"page.about.author": "المؤلف:",
|
||||
"page.about.build_date": "تاريخ البناء:",
|
||||
"page.about.credits": "شكر وتقدير",
|
||||
"page.about.db_usage": "حجم قاعدة البيانات:",
|
||||
"page.about.git_commit": "التزام Git:",
|
||||
"page.about.global_config_options": "خيارات التكوين العامة",
|
||||
"page.about.go_version": "إصدار Go:",
|
||||
"page.about.license": "الرخصة:",
|
||||
"page.about.postgres_version": "إصدار Postgres:",
|
||||
"page.about.title": "حول",
|
||||
"page.about.version": "الإصدار:",
|
||||
"page.add_feed.choose_feed": "اختر مصدراً",
|
||||
"page.add_feed.label.url": "الرابط",
|
||||
"page.add_feed.legend.advanced_options": "خيارات متقدمة",
|
||||
"page.add_feed.no_category": "لا توجد فئة. يجب أن يكون لديك فئة واحدة على الأقل.",
|
||||
"page.add_feed.submit": "البحث عن مصدر",
|
||||
"page.add_feed.title": "مصدر جديد",
|
||||
"page.api_keys.never_used": "لم يُستخدم أبداً",
|
||||
"page.api_keys.table.actions": "الإجراءات",
|
||||
"page.api_keys.table.created_at": "تاريخ الإنشاء",
|
||||
"page.api_keys.table.description": "الوصف",
|
||||
"page.api_keys.table.last_used_at": "آخر استخدام",
|
||||
"page.api_keys.table.token": "الرمز",
|
||||
"page.api_keys.title": "مفاتيح API",
|
||||
"page.categories.entries": "المقالات",
|
||||
"page.categories.feed_count": [
|
||||
"لا يوجد مصادر.",
|
||||
"يوجد مصدر واحد.",
|
||||
"يوجد مصدران.",
|
||||
"يوجد %d مصادر.",
|
||||
"يوجد %d مصدراً.",
|
||||
"يوجد %d مصدراً."
|
||||
],
|
||||
"page.categories.feeds": "المصادر",
|
||||
"page.categories.no_feed": "لا يوجد مصدر.",
|
||||
"page.categories.title": "الفئات",
|
||||
"page.categories_count": [
|
||||
"%d فئة",
|
||||
"فئة واحدة",
|
||||
"فئتان",
|
||||
"%d فئات",
|
||||
"%d فئة",
|
||||
"%d فئة"
|
||||
],
|
||||
"page.category_label": "الفئة: %s",
|
||||
"page.edit_category.title": "تعديل الفئة: %s",
|
||||
"page.edit_feed.etag_header": "رأس ETag:",
|
||||
"page.edit_feed.last_check": "آخر فحص:",
|
||||
"page.edit_feed.last_modified_header": "رأس LastModified:",
|
||||
"page.edit_feed.last_parsing_error": "آخر خطأ تحليل",
|
||||
"page.edit_feed.no_header": "لا يوجد",
|
||||
"page.edit_feed.title": "تعديل المصدر: %s",
|
||||
"page.edit_user.title": "تعديل المستخدم: %s",
|
||||
"page.entry.attachments": "مرفقات",
|
||||
"page.feeds.error_count": [
|
||||
"%d خطأ",
|
||||
"خطأ واحد",
|
||||
"خطآن",
|
||||
"%d أخطاء",
|
||||
"%d خطأً",
|
||||
"%d خطأً"
|
||||
],
|
||||
"page.feeds.last_check": "آخر فحص:",
|
||||
"page.feeds.next_check": "الفحص التالي:",
|
||||
"page.feeds.read_counter": "عدد المقالات المقروءة",
|
||||
"page.feeds.title": "المصادر",
|
||||
"page.footer.elevator": "العودة للأعلى",
|
||||
"page.history.title": "السجل",
|
||||
"page.import.title": "استيراد",
|
||||
"page.integration.bookmarklet": "أداة الإشارة المرجعية (Bookmarklet)",
|
||||
"page.integration.bookmarklet.help": "يتيح لك هذا الرابط الخاص الاشتراك في أي موقع ويب مباشرةً باستخدام إشارة مرجعية في متصفح الويب الخاص بك.",
|
||||
"page.integration.bookmarklet.instructions": "اسحب وأفلت هذا الرابط إلى إشاراتك المرجعية.",
|
||||
"page.integration.bookmarklet.name": "إضافة إلى Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "نقطة نهاية API",
|
||||
"page.integration.miniflux_api_password": "كلمة المرور",
|
||||
"page.integration.miniflux_api_password_value": "كلمة مرور حسابك",
|
||||
"page.integration.miniflux_api_username": "اسم المستخدم",
|
||||
"page.integrations.title": "خدمات مرتبطة",
|
||||
"page.keyboard_shortcuts.close_modal": "إغلاق النافذة المنبثقة",
|
||||
"page.keyboard_shortcuts.download_content": "تحميل المحتوى الأصلي",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "الذهاب إلى آخر عنصر",
|
||||
"page.keyboard_shortcuts.go_to_categories": "الذهاب إلى الفئات",
|
||||
"page.keyboard_shortcuts.go_to_feed": "الذهاب إلى المصدر",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "الذهاب إلى المصادر",
|
||||
"page.keyboard_shortcuts.go_to_history": "الذهاب إلى السجل",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "الذهاب إلى العنصر التالي",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "الذهاب إلى الصفحة التالية",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "الذهاب إلى العنصر السابق",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "الذهاب إلى الصفحة السابقة",
|
||||
"page.keyboard_shortcuts.go_to_search": "التركيز على نموذج البحث",
|
||||
"page.keyboard_shortcuts.go_to_settings": "الذهاب إلى الإعدادات",
|
||||
"page.keyboard_shortcuts.go_to_starred": "الذهاب إلى المفضلة",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "الذهاب إلى أعلى عنصر",
|
||||
"page.keyboard_shortcuts.go_to_unread": "الذهاب إلى غير المقروءة",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "تحديد الصفحة الحالية كمقروءة",
|
||||
"page.keyboard_shortcuts.open_comments": "فتح رابط التعليقات",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "فتح رابط التعليقات في التبويب الحالي",
|
||||
"page.keyboard_shortcuts.open_item": "فتح العنصر المحدد",
|
||||
"page.keyboard_shortcuts.open_original": "فتح الرابط الأصلي",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "فتح الرابط الأصلي في التبويب الحالي",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "تحديث جميع المصادر في الخلفية",
|
||||
"page.keyboard_shortcuts.remove_feed": "حذف هذا المصدر",
|
||||
"page.keyboard_shortcuts.save_article": "حفظ المقال",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "تمرير العنصر إلى الأعلى",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "الإجراءات",
|
||||
"page.keyboard_shortcuts.subtitle.items": "التنقل بين العناصر",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "التنقل بين الصفحات",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "التنقل بين الأقسام",
|
||||
"page.keyboard_shortcuts.title": "اختصارات لوحة المفاتيح",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "تبديل المفضلة",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "تبديل فتح/إغلاق مرفقات المقال",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "تبديل مقروء/غير مقروء، التركيز على التالي",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "تبديل مقروء/غير مقروء، التركيز على السابق",
|
||||
"page.login.google_signin": "تسجيل الدخول باستخدام Google",
|
||||
"page.login.oidc_signin": "تسجيل الدخول باستخدام %s",
|
||||
"page.login.title": "تسجيل الدخول",
|
||||
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
|
||||
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
|
||||
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
|
||||
"page.new_api_key.title": "مفتاح API جديد",
|
||||
"page.new_category.title": "فئة جديدة",
|
||||
"page.new_user.title": "مستخدم جديد",
|
||||
"page.offline.message": "أنت غير متصل بالإنترنت",
|
||||
"page.offline.refresh_page": "حاول تحديث الصفحة",
|
||||
"page.offline.title": "وضع عدم الاتصال",
|
||||
"page.read_entry_count": [
|
||||
"%d مقال مقروء",
|
||||
"مقال واحد مقروء",
|
||||
"مقالان مقروءان",
|
||||
"%d مقالات مقروءة",
|
||||
"%d مقالاً مقروءاً",
|
||||
"%d مقالاً مقروءاً"
|
||||
],
|
||||
"page.search.title": "نتائج البحث",
|
||||
"page.sessions.table.actions": "الإجراءات",
|
||||
"page.sessions.table.current_session": "الجلسة الحالية",
|
||||
"page.sessions.table.date": "التاريخ",
|
||||
"page.sessions.table.ip": "عنوان IP",
|
||||
"page.sessions.table.user_agent": "وكيل المستخدم (User Agent)",
|
||||
"page.sessions.title": "الجلسات",
|
||||
"page.settings.link_google_account": "ربط حسابي في Google",
|
||||
"page.settings.link_oidc_account": "ربط حسابي في %s",
|
||||
"page.settings.title": "الإعدادات",
|
||||
"page.settings.unlink_google_account": "فك ارتباط حسابي في Google",
|
||||
"page.settings.unlink_oidc_account": "فك ارتباط حسابي في %s",
|
||||
"page.settings.webauthn.actions": "الإجراءات",
|
||||
"page.settings.webauthn.added_on": "أضيف في",
|
||||
"page.settings.webauthn.delete": [
|
||||
"إزالة %d مفتاح مرور",
|
||||
"إزالة مفتاح مرور واحد",
|
||||
"إزالة مفتاحي مرور",
|
||||
"إزالة %d مفاتيح مرور",
|
||||
"إزالة %d مفتاح مرور",
|
||||
"إزالة %d مفتاح مرور"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "آخر استخدام",
|
||||
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
|
||||
"page.settings.webauthn.passkeys": "مفاتيح المرور",
|
||||
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
|
||||
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
|
||||
"page.shared_entries.title": "المقالات المشاركة",
|
||||
"page.shared_entries_count": [
|
||||
"%d مقال مشترك",
|
||||
"مقال واحد مشترك",
|
||||
"مقالان مشتركان",
|
||||
"%d مقالات مشتركة",
|
||||
"%d مقالاً مشتركاً",
|
||||
"%d مقالاً مشتركاً"
|
||||
],
|
||||
"page.starred.title": "المفضلة",
|
||||
"page.starred_entry_count": [
|
||||
"%d مقال مفضل",
|
||||
"مقال واحد مفضل",
|
||||
"مقالان مفضلان",
|
||||
"%d مقالات مفضلة",
|
||||
"%d مقالاً مفضلاً",
|
||||
"%d مقالاً مفضلاً"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d مقال في الإجمالي",
|
||||
"مقال واحد في الإجمالي",
|
||||
"مقالان في الإجمالي",
|
||||
"%d مقالات في الإجمالي",
|
||||
"%d مقالاً في الإجمالي",
|
||||
"%d مقالاً في الإجمالي"
|
||||
],
|
||||
"page.unread.title": "غير المقروءة",
|
||||
"page.unread_entry_count": [
|
||||
"%d مقال غير مقروء",
|
||||
"مقال واحد غير مقروء",
|
||||
"مقالان غير مقروءين",
|
||||
"%d مقالات غير مقروءة",
|
||||
"%d مقالاً غير مقروء",
|
||||
"%d مقالاً غير مقروء"
|
||||
],
|
||||
"page.users.actions": "الإجراءات",
|
||||
"page.users.admin.no": "لا",
|
||||
"page.users.admin.yes": "نعم",
|
||||
"page.users.is_admin": "مدير",
|
||||
"page.users.last_login": "آخر دخول",
|
||||
"page.users.never_logged": "أبداً",
|
||||
"page.users.title": "المستخدمون",
|
||||
"page.users.username": "اسم المستخدم",
|
||||
"page.webauthn_rename.title": "إعادة تسمية مفتاح المرور",
|
||||
"pagination.first": "الأول",
|
||||
"pagination.last": "الأخير",
|
||||
"pagination.next": "التالي",
|
||||
"pagination.previous": "السابق",
|
||||
"search.label": "بحث",
|
||||
"search.placeholder": "بحث...",
|
||||
"search.submit": "بحث",
|
||||
"skip_to_content": "تخطي إلى المحتوى",
|
||||
"time_elapsed.days": [
|
||||
"منذ %d يوم",
|
||||
"منذ يوم واحد",
|
||||
"منذ يومين",
|
||||
"منذ %d أيام",
|
||||
"منذ %d يوماً",
|
||||
"منذ %d يوماً"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"منذ %d ساعة",
|
||||
"منذ ساعة واحدة",
|
||||
"منذ ساعتين",
|
||||
"منذ %d ساعات",
|
||||
"منذ %d ساعة",
|
||||
"منذ %d ساعة"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"منذ %d دقيقة",
|
||||
"منذ دقيقة واحدة",
|
||||
"منذ دقيقتين",
|
||||
"منذ %d دقائق",
|
||||
"منذ %d دقيقة",
|
||||
"منذ %d دقيقة"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"منذ %d شهر",
|
||||
"منذ شهر واحد",
|
||||
"منذ شهرين",
|
||||
"منذ %d أشهر",
|
||||
"منذ %d شهراً",
|
||||
"منذ %d شهراً"
|
||||
],
|
||||
"time_elapsed.not_yet": "ليس بعد",
|
||||
"time_elapsed.now": "الآن",
|
||||
"time_elapsed.weeks": [
|
||||
"منذ %d أسبوع",
|
||||
"منذ أسبوع واحد",
|
||||
"منذ أسبوعين",
|
||||
"منذ %d أسابيع",
|
||||
"منذ %d أسبوعاً",
|
||||
"منذ %d أسبوعاً"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"منذ %d سنة",
|
||||
"منذ سنة واحدة",
|
||||
"منذ سنتين",
|
||||
"منذ %d سنوات",
|
||||
"منذ %d سنة",
|
||||
"منذ %d سنة"
|
||||
],
|
||||
"time_elapsed.yesterday": "أمس",
|
||||
"tooltip.keyboard_shortcuts": "اختصار لوحة المفاتيح: %s",
|
||||
"tooltip.logged_user": "تم تسجيل الدخول باسم %s"
|
||||
}
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Beschreibung",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Κατηγορία",
|
||||
"form.feed.label.cookie": "Ορισμός Cookies",
|
||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Περιγραφή",
|
||||
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
|
||||
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Category",
|
||||
"form.feed.label.cookie": "Set Cookies",
|
||||
"form.feed.label.crawler": "Fetch original content",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disabled": "Do not refresh this feed",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.crawler": "Obtener rastreador original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Descripción",
|
||||
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.cookie": "Aseta evästeet",
|
||||
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Kuvaus",
|
||||
"form.feed.label.disable_http2": "Poista HTTP/2 käytöstä sormenjälkien välttämiseksi",
|
||||
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Catégorie",
|
||||
"form.feed.label.cookie": "Définir les cookies",
|
||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.disable_http2": "Désactiver HTTP/2",
|
||||
"form.feed.label.disabled": "Ne pas actualiser ce flux",
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
{
|
||||
"action.cancel": "cancelar",
|
||||
"action.download": "Descargar",
|
||||
"action.edit": "Editar",
|
||||
"action.home_screen": "Engadir á pantalla de inicio",
|
||||
"action.import": "Importar",
|
||||
"action.login": "Acceso",
|
||||
"action.or": "ou",
|
||||
"action.remove": "Retirar",
|
||||
"action.remove_feed": "Retirar esta canle",
|
||||
"action.save": "Gardar",
|
||||
"action.subscribe": "Dar de baixa",
|
||||
"action.update": "Actualizar",
|
||||
"alert.account_linked": "Conectouse a túa conta externa!",
|
||||
"alert.account_unlinked": "Desconectouse a túa conta externa!",
|
||||
"alert.background_feed_refresh": "Estanse actualizando en segundo plano todas as canles. Podes continuar usando Miniflux mentras se realiza a actualización.",
|
||||
"alert.feed_error": "Hai un problema con esta canle.",
|
||||
"alert.no_starred": "Non hai artigos con estrela.",
|
||||
"alert.no_category": "Non hai categorías.",
|
||||
"alert.no_category_entry": "Non hai artigos nesta categoría.",
|
||||
"alert.no_feed": "Non engadiches ningunha canle.",
|
||||
"alert.no_feed_entry": "Non hai artigos para esta canle.",
|
||||
"alert.no_feed_in_category": "Non hai canles nesta categoría.",
|
||||
"alert.no_history": "Por agora non hai historial.",
|
||||
"alert.no_search_result": "Non hai resultados para esta busca.",
|
||||
"alert.no_shared_entry": "Non hai artigos compartidos.",
|
||||
"alert.no_tag_entry": "Non hai artigos con esta etiqueta.",
|
||||
"alert.no_unread_entry": "Non hai artigos sen ler.",
|
||||
"alert.no_user": "Es a única conta usuaria.",
|
||||
"alert.prefs_saved": "Gardáronse as preferencias!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Intentaches demasiadas actualizacións da canle. Agarda %d minuto antes de volver intentalo.",
|
||||
"Intentaches demasiadas actualizacións da canle. Agarda %d minutos antes de volver intentalo."
|
||||
],
|
||||
"confirm.loading": "En proceso…",
|
||||
"confirm.no": "non",
|
||||
"confirm.question": "Tes certeza?",
|
||||
"confirm.question.refresh": "Tes certeza de querer forzar a actualización?",
|
||||
"confirm.yes": "si",
|
||||
"enclosure_media_controls.seek": "Avanzar:",
|
||||
"enclosure_media_controls.seek.title": "Avanzar %s segundos",
|
||||
"enclosure_media_controls.speed": "Velocidade:",
|
||||
"enclosure_media_controls.speed.faster": "Máis rápido",
|
||||
"enclosure_media_controls.speed.faster.title": "Máis rápido %sx",
|
||||
"enclosure_media_controls.speed.reset": "Restablecer",
|
||||
"enclosure_media_controls.speed.reset.title": "Restablecer velocidade a 1x",
|
||||
"enclosure_media_controls.speed.slower": "Máis lento",
|
||||
"enclosure_media_controls.speed.slower.title": "Máis lento %sx",
|
||||
"entry.starred.toast.off": "Sen estrela",
|
||||
"entry.starred.toast.on": "Con estrela",
|
||||
"entry.starred.toggle.off": "Retirar estrela",
|
||||
"entry.starred.toggle.on": "Pór estrela",
|
||||
"entry.comments.label": "Comentarios",
|
||||
"entry.comments.title": "Ver comentarios",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minuto de lectura",
|
||||
"%d minutos de lectura"
|
||||
],
|
||||
"entry.external_link.label": "Ligazón externa",
|
||||
"entry.save.completed": "Feito!",
|
||||
"entry.save.label": "Gardar",
|
||||
"entry.save.title": "Gardar este artigo",
|
||||
"entry.save.toast.completed": "Gardouse o artigo",
|
||||
"entry.scraper.completed": "Feito!",
|
||||
"entry.scraper.label": "Descargar",
|
||||
"entry.scraper.title": "Obter contido orixinal",
|
||||
"entry.share.label": "Compartir",
|
||||
"entry.share.title": "Compartir este artigo",
|
||||
"entry.shared_entry.label": "Compartir",
|
||||
"entry.shared_entry.title": "Abrir ligazón pública",
|
||||
"entry.state.loading": "Cargando…",
|
||||
"entry.state.saving": "Gardando…",
|
||||
"entry.status.mark_as_read": "Marcar como lido",
|
||||
"entry.status.mark_as_unread": "Marcar como non lido",
|
||||
"entry.status.title": "Cambiar estado do artigo",
|
||||
"entry.status.toast.read": "Marcado como lido",
|
||||
"entry.status.toast.unread": "Marcado como non lido",
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"Mostrar %d etiqueta máis",
|
||||
"Mostrar %d etiquetas máis"
|
||||
],
|
||||
"entry.unshare.label": "Non compartir",
|
||||
"error.api_key_already_exists": "Xa existe esta clave da API.",
|
||||
"error.bad_credentials": "Credenciais incorrectas.",
|
||||
"error.category_already_exists": "Xa existe a categoría.",
|
||||
"error.category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
|
||||
"error.database_error": "Erro na base de datos: %v.",
|
||||
"error.different_passwords": "Os contrasinais non coinciden.",
|
||||
"error.duplicate_fever_username": "Xa hai alguén con ese identificador en Fever!",
|
||||
"error.duplicate_googlereader_username": "Xa hai alguén con ese identificador en Google Reader!",
|
||||
"error.linktaco_missing_required_fields": "Requírese LinkTaco API Token e Organization Slug",
|
||||
"error.duplicate_linked_account": "Xa hai alguén asociado con este provedor!",
|
||||
"error.duplicated_feed": "Xa existe a canle.",
|
||||
"error.empty_file": "O ficheiro está baleiro.",
|
||||
"error.entries_per_page_invalid": "O número de artigos por páxina non é válido.",
|
||||
"error.feed_already_exists": "Xa existe a canle.",
|
||||
"error.feed_category_not_found": "Non existe a categoría ou non pertence a esta usuaria.",
|
||||
"error.feed_format_not_detected": "Non se puido detectar o formato da canle: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueo non é válida.",
|
||||
"error.feed_invalid_keeplist_rule": "A regra da lista a manter non é válida.",
|
||||
"error.feed_mandatory_fields": "É obrigatorio engadir un URL para a categoría.",
|
||||
"error.feed_not_found": "A canle non existe ou non pertence a esta usuaria.",
|
||||
"error.feed_title_not_empty": "O título da canle non pode quedar baleiro.",
|
||||
"error.feed_url_not_empty": "O URL da canle non pode quedar baleiro.",
|
||||
"error.fields_mandatory": "Son obrigatorios todos os campos.",
|
||||
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
|
||||
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
|
||||
"error.http_client_error": "Erro HTTP no cliente: %v.",
|
||||
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
|
||||
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
|
||||
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
|
||||
"error.http_gateway_timeout": "O sitio web non está dispoñible debido a un erro de caducidade na pasarela. O problema non está en Miniflux. Volve a intentalo máis tarde.",
|
||||
"error.http_internal_server_error": "O sitio web non está dispoñible debido a un fallo no servidor. O problema non está en Miniflux. Volve a intentalo máis tarde.",
|
||||
"error.http_not_authorized": "Non hai autorización para acceder a este sitio web. Podería deberse a unhas credenciais non válidas.",
|
||||
"error.http_resource_not_found": "Non se atopa o recurso solicitado. Por favor, comproba o URL.",
|
||||
"error.http_response_too_large": "A resposta HTTP é demasiado grande. Podes aumentar o límite da resposta HTTP nos axustes xerais (require reinicio do servidor).",
|
||||
"error.http_service_unavailable": "O sitio web non está dispoñible neste momento debido a un erro interno do servidor. O problema non está en Miniflux, inténtao máis tarde.",
|
||||
"error.http_too_many_requests": "Miniflux xerou demasiadas peticións a este servidor. Inténtao máis tarde ou cambia a configuración da aplicación.",
|
||||
"error.http_unexpected_status_code": "O sitio web non está dispoñible neste momento debido a un código de estado HTTP non agardado: %d. O problema non está en Miniflux, inténtao máis tarde.",
|
||||
"error.invalid_categories_sorting_order": "Orde por categorías non válido.",
|
||||
"error.invalid_default_home_page": "Páxina de inicio predeterminada non válida!",
|
||||
"error.invalid_display_mode": "Modo para mostrar a aplicación web non válido.",
|
||||
"error.invalid_entry_direction": "Dirección da entrada non válida.",
|
||||
"error.invalid_entry_order": "Orde da entrada non válida.",
|
||||
"error.invalid_feed_proxy_url": "URL do mandatario non válido.",
|
||||
"error.invalid_feed_url": "URL da canle non válido.",
|
||||
"error.invalid_gesture_nav": "Xesto de navegación non válido.",
|
||||
"error.invalid_language": "Idioma non válido.",
|
||||
"error.invalid_site_url": "URL da web non válido.",
|
||||
"error.invalid_theme": "Decorado non válido.",
|
||||
"error.invalid_timezone": "Zona horaria non válida.",
|
||||
"error.network_operation": "Miniflux non pode acadar esta web por mor dun erro na rede: %v.",
|
||||
"error.network_timeout": "Esta web é demasiado lenta e caducou a petición: %v",
|
||||
"error.password_min_length": "O contrasinal ten que ter 6 caracteres polo menos.",
|
||||
"error.proxy_url_not_empty": "O URL do mandatario non pode quedar baleiro.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regra do Bloque non válida: á regra #%d fáltalle un nome de campo válido (Opcións: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Regra do Bloque non válida: o patrón da regra #%d non é unha expresión regex válida",
|
||||
"error.settings_block_rule_regex_required": "Regra do Bloque non válida: non se proporcionou o patrón da regra #%d",
|
||||
"error.settings_block_rule_separator_required": "Regra do Bloque non válida:: o patrón da regra #%d require estar separado por un '='",
|
||||
"error.settings_invalid_domain_list": "Lista de dominios non válida. Proporciona unha lista de dominios separados por espazos.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regra para Manter non válida: a regra #%d non ten un nome de campo válido (Opcións: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Regra para Manter non válida: o patrón da regra #%d non é unha expresión regex válida",
|
||||
"error.settings_keep_rule_regex_required": "Regra para Manter non válida: non se proporcionou o patrón para #%d",
|
||||
"error.settings_keep_rule_separator_required": "Regra para Manter non válida: o patrón da regra #%d ten que estar separado por un '='",
|
||||
"error.settings_mandatory_fields": "O identificador, decorado, idioma e zona horaria son campos obrigatorios.",
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodución está fóra do rango admitido",
|
||||
"error.settings_reading_speed_is_positive": "A velocidade de lectura ten que ser un número enteiro positivo.",
|
||||
"error.site_url_not_empty": "O URL da web non pode estar baleiro.",
|
||||
"error.subscription_not_found": "Non se atopou ningunha canle.",
|
||||
"error.title_required": "O título é obrigatorio.",
|
||||
"error.tls_error": "Erro TLS: %q. Podes desactivar a verificación TLS nos axustes da canle se queres.",
|
||||
"error.unable_to_create_api_key": "Non se puido crear a clave da API.",
|
||||
"error.unable_to_create_category": "Non se puido crear a categoría.",
|
||||
"error.unable_to_create_user": "Non se puido crear a conta.",
|
||||
"error.unable_to_detect_rssbridge": "Non se detectou a canle a usar RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Non se puido procesar a canle: %v.",
|
||||
"error.unable_to_update_category": "Non se puido actualizar a categoría.",
|
||||
"error.unable_to_update_feed": "Non se puido actualizar a canle.",
|
||||
"error.unable_to_update_user": "Non se puido actualizar a usuaria.",
|
||||
"error.unlink_account_without_password": "Tes que crear un contrasinal, se non non poderás volver acceder.",
|
||||
"error.user_already_exists": "Xa existe esta usuaria.",
|
||||
"error.user_mandatory_fields": "O identificador é obrigatorio.",
|
||||
"form.api_key.label.description": "Etiqueta da Clave da API",
|
||||
"form.category.hide_globally": "Ocultar entradas na lista global de non lidos",
|
||||
"form.category.label.title": "Título",
|
||||
"form.feed.fieldset.general": "Xeral",
|
||||
"form.feed.fieldset.integration": "Servizos de Terceiras Partes",
|
||||
"form.feed.fieldset.network_settings": "Axustes da rede",
|
||||
"form.feed.fieldset.rules": "Regras",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
|
||||
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.cookie": "Establecer rastros",
|
||||
"form.feed.label.crawler": "Obter contido orixinal",
|
||||
"form.feed.label.description": "Descrición",
|
||||
"form.feed.label.disable_http2": "Desactivar HTTP/2 para evitar «fingerprinting»",
|
||||
"form.feed.label.disabled": "Non actualizar esta canle",
|
||||
"form.feed.label.feed_password": "Contrasinal para a canle",
|
||||
"form.feed.label.feed_url": "URL da canle",
|
||||
"form.feed.label.feed_username": "Identificador para a canle",
|
||||
"form.feed.label.fetch_via_proxy": "Usar o mandatario configurado a nivel da aplicación",
|
||||
"form.feed.label.hide_globally": "Ocultar entradas na lista global de non lidos",
|
||||
"form.feed.label.ignore_entry_updates": "Ignorar actualizacións da entrada",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar memoria tobo HTTP",
|
||||
"form.feed.label.keep_filter_entry_rules": "Regra para Entradas permitidas",
|
||||
"form.feed.label.keeplist_rules": "Filtros para Manter baseados en RegEx",
|
||||
"form.feed.label.no_media_player": "Sen reprodutor (son/vídeo)",
|
||||
"form.feed.label.ntfy_activate": "Enviar novidades a Ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridade predeterminada Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Alta prioridade Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Baix prioridade Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioridade máx. Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioridade mín. Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridade en Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Tema en Ntfy (optativo)",
|
||||
"form.feed.label.proxy_url": "URL do mandatario",
|
||||
"form.feed.label.pushover_activate": "Enviar novidades a Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Prioridade predeterminada",
|
||||
"form.feed.label.pushover_high_priority": "Alta prioridade",
|
||||
"form.feed.label.pushover_low_priority": "Baixa prioridade",
|
||||
"form.feed.label.pushover_max_priority": "Prioridade máx.",
|
||||
"form.feed.label.pushover_min_priority": "Prioridade mín.",
|
||||
"form.feed.label.pushover_priority": "Prioridade da mensaxe Pushover",
|
||||
"form.feed.label.rewrite_rules": "Regras de Reescritura do contido",
|
||||
"form.feed.label.scraper_rules": "Regras ao obter contido",
|
||||
"form.feed.label.site_url": "URL do sitio",
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.urlrewrite_rules": "Regras de rescritura URL",
|
||||
"form.feed.label.user_agent": "Sobrescribir User Agent predeterminado",
|
||||
"form.feed.label.webhook_url": "Override webhook url",
|
||||
"form.import.label.file": "Ficheiro OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
|
||||
"form.integration.apprise_activate": "Enviar entradas a Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.integration.apprise_url": "URL de Apprise API",
|
||||
"form.integration.betula_activate": "Gardar entradas en Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"form.integration.betula_url": "URL do servidor Betula",
|
||||
"form.integration.cubox_activate": "Gardar entradas en Cubox",
|
||||
"form.integration.cubox_api_link": "Ligazón a Cubox API",
|
||||
"form.integration.discord_activate": "Enviar entradas a Discord",
|
||||
"form.integration.discord_webhook_link": "Ligazón a Discord Webhook",
|
||||
"form.integration.espial_activate": "Enviar entradas a Espial",
|
||||
"form.integration.espial_api_key": "Clave de Espial API",
|
||||
"form.integration.espial_endpoint": "Acceso na Espial API",
|
||||
"form.integration.espial_tags": "Etiquetas Espial",
|
||||
"form.integration.fever_activate": "Activar Fever API",
|
||||
"form.integration.fever_endpoint": "Punto de acceso da Fever API:",
|
||||
"form.integration.fever_password": "Contrasinal Fever",
|
||||
"form.integration.fever_username": "Identificador Fever",
|
||||
"form.integration.googlereader_activate": "Activar API Google Reader",
|
||||
"form.integration.googlereader_endpoint": "Punto de acceso de Google Reader API:",
|
||||
"form.integration.googlereader_password": "Contrasinal Google Reader",
|
||||
"form.integration.googlereader_username": "Identificador Google Reader",
|
||||
"form.integration.instapaper_activate": "Gardar entradas en Instapaper",
|
||||
"form.integration.instapaper_password": "Contrasinal Instapaper",
|
||||
"form.integration.instapaper_username": "Identificador Instapaper",
|
||||
"form.integration.karakeep_activate": "Gardar entradas en Karakeep",
|
||||
"form.integration.karakeep_api_key": "Clave de Karakeep API",
|
||||
"form.integration.karakeep_url": "Punto de acceso da Karakeep API",
|
||||
"form.integration.karakeep_tags": "Etiquetas Karakeep",
|
||||
"form.integration.linkace_activate": "Gardar entradas en LinkAce",
|
||||
"form.integration.linkace_api_key": "Clave de LinkAce API",
|
||||
"form.integration.linkace_check_disabled": "Desactivar comprobación de ligazóns",
|
||||
"form.integration.linkace_endpoint": "Punto de acceso da LinkAce API",
|
||||
"form.integration.linkace_is_private": "Marcar ligazón como privada",
|
||||
"form.integration.linkace_tags": "Etiquetas LinkAce",
|
||||
"form.integration.linkding_activate": "Gardar entradas en Linkding",
|
||||
"form.integration.linkding_api_key": "Clave da Linkding API",
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como non lido",
|
||||
"form.integration.linkding_endpoint": "Punto de acceso da Linkding API",
|
||||
"form.integration.linkding_tags": "Etiquetas Linkding",
|
||||
"form.integration.linktaco_activate": "Gardar entradas en LinkTaco",
|
||||
"form.integration.linktaco_api_token": "Token da API de LinkTaco",
|
||||
"form.integration.linktaco_api_token_hint": "Obtén o token de acceso persoal en",
|
||||
"form.integration.linktaco_org_slug": "Sobrenome da Organización",
|
||||
"form.integration.linktaco_tags": "Etiquetas (máx 10, separadas por comas)",
|
||||
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
|
||||
"form.integration.linktaco_visibility": "Visibilidade",
|
||||
"form.integration.linktaco_visibility_public": "Pública",
|
||||
"form.integration.linktaco_visibility_private": "Privada",
|
||||
"form.integration.linktaco_visibility_hint": "A visibilidade privada require unha conta de pago de LinkTaco",
|
||||
"form.integration.linkwarden_activate": "Gardar entradas en Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Clave da Linkwarden API",
|
||||
"form.integration.linkwarden_endpoint": "URL Base de Linkwarden",
|
||||
"form.integration.linkwarden_collection_id": "ID da colección Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Enviar entradas a Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID da Sala Matrix",
|
||||
"form.integration.matrix_bot_password": "Contrasinal da usuaria Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_user": "Identificador en Matrix",
|
||||
"form.integration.notion_activate": "Gardar entradas en Notion",
|
||||
"form.integration.notion_page_id": "ID da páxina Notion",
|
||||
"form.integration.notion_token": "Token secreto para Notion",
|
||||
"form.integration.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.integration.ntfy_api_token": "Token da Ntfy API (optativo)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optativo)",
|
||||
"form.integration.ntfy_internal_links": "Usar ligazóns internas ao premer (optativo)",
|
||||
"form.integration.ntfy_password": "Contrasinal Ntfy (optativo)",
|
||||
"form.integration.ntfy_topic": "Tema en Ntfy (úsase por defecto se non o establece a canle)",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optativo, predeterminado ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Contrasinal Ntfy (optativo)",
|
||||
"form.integration.nunux_keeper_activate": "Gardar entradas en Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Clave da Nunux Keeper API",
|
||||
"form.integration.nunux_keeper_endpoint": "Punto de acceso Nunux Keeper API",
|
||||
"form.integration.omnivore_activate": "Gardar entradas en Omnivore",
|
||||
"form.integration.omnivore_api_key": "Clave da Omnivore API",
|
||||
"form.integration.omnivore_url": "Punto de acceso de Omnivore API",
|
||||
"form.integration.pinboard_activate": "Gardar entradas en Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Marcar marcador como non lido",
|
||||
"form.integration.pinboard_tags": "Etiquetas Pinboard",
|
||||
"form.integration.pinboard_token": "Token da Pinboard API",
|
||||
"form.integration.pushover_activate": "Enviar entradas a Pushover",
|
||||
"form.integration.pushover_device": "Dispositivo Pushover (optativo)",
|
||||
"form.integration.pushover_prefix": "Prefixo do URL Pushover (optativo)",
|
||||
"form.integration.pushover_token": "Token da aplicación na Pushover API",
|
||||
"form.integration.pushover_user": "Clave da usuaria Pushover",
|
||||
"form.integration.raindrop_activate": "Gardar entradas en Raindrop",
|
||||
"form.integration.raindrop_collection_id": "ID da colección",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"form.integration.raindrop_token": "Token (de proba)",
|
||||
"form.integration.readeck_activate": "Gardar entradas en Readeck",
|
||||
"form.integration.readeck_api_key": "Clave da Readeck API",
|
||||
"form.integration.readeck_endpoint": "URL de Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
|
||||
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obter o token de acceso a Readwise",
|
||||
"form.integration.rssbridge_activate": "Comprobar RSS-Bridge ao engadir subscricións",
|
||||
"form.integration.rssbridge_token": "Token de autenticación RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Gardar artigos en Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Clave secreta da API Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Punto de acceso Shaarli",
|
||||
"form.integration.shiori_activate": "Gardar artigos en Shiori",
|
||||
"form.integration.shiori_endpoint": "Punto de acceso Shiori API",
|
||||
"form.integration.shiori_password": "Contrasinal Shiori",
|
||||
"form.integration.shiori_username": "Identificador Shiori",
|
||||
"form.integration.slack_activate": "Enviar entradas a Slack",
|
||||
"form.integration.slack_webhook_link": "Ligzón de Slack Webhook",
|
||||
"form.integration.telegram_bot_activate": "Enviar novas entradas a parola Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
|
||||
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
|
||||
"form.integration.telegram_bot_token": "Toke do Bot",
|
||||
"form.integration.telegram_chat_id": "ID da parola",
|
||||
"form.integration.telegram_topic_id": "ID do tema",
|
||||
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
|
||||
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.wallabag_password": "Contrasinal en Wallabag",
|
||||
"form.integration.wallabag_username": "Identificador en Wallabag",
|
||||
"form.integration.wallabag_tags": "Etiquetas Wallabag",
|
||||
"form.integration.webhook_activate": "Activar Webhooks",
|
||||
"form.integration.webhook_secret": "Clave secreta Webhooks",
|
||||
"form.integration.webhook_url": "URL predeterminada Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
|
||||
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
|
||||
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
|
||||
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Ler artigos abringo ligazóns externas",
|
||||
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
|
||||
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
|
||||
"form.prefs.label.custom_css": "CSS persoal",
|
||||
"form.prefs.label.custom_js": "JavaScript persoal",
|
||||
"form.prefs.label.default_home_page": "Páxina de inicio predeterminada",
|
||||
"form.prefs.label.default_reading_speed": "Velocidade de lectura para outros idiomas (palabras por minuto)",
|
||||
"form.prefs.label.display_mode": "Disposición da interface Progressive Web App (PWA)",
|
||||
"form.prefs.label.entries_per_page": "Entradas por páxina",
|
||||
"form.prefs.label.entry_order": "Columna para orde das entradas",
|
||||
"form.prefs.label.entry_sorting": "Orde das entradas",
|
||||
"form.prefs.label.entry_swipe": "Activar o desprazamento de entradas en pantallas táctiles",
|
||||
"form.prefs.label.external_font_hosts": "Servidores externos de tipografías",
|
||||
"form.prefs.label.gesture_nav": "Xestos para moverse entre entradas",
|
||||
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
|
||||
"form.prefs.label.language": "Idioma",
|
||||
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
|
||||
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
|
||||
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
|
||||
"form.prefs.label.theme": "Decorado",
|
||||
"form.prefs.label.timezone": "Zona horaria",
|
||||
"form.prefs.select.alphabetical": "Alfabética",
|
||||
"form.prefs.select.browser": "Navegador",
|
||||
"form.prefs.select.created_time": "Hora de creación da entrada",
|
||||
"form.prefs.select.fullscreen": "Pantalla completa",
|
||||
"form.prefs.select.minimal_ui": "Mínima",
|
||||
"form.prefs.select.none": "Ningunha",
|
||||
"form.prefs.select.older_first": "Primeiro as antigas",
|
||||
"form.prefs.select.publish_time": "Hora de publicación da entrada",
|
||||
"form.prefs.select.recent_first": "Primeiro as recentes",
|
||||
"form.prefs.select.standalone": "Standalone",
|
||||
"form.prefs.select.swipe": "Desprazar",
|
||||
"form.prefs.select.tap": "Doble toque",
|
||||
"form.prefs.select.unread_count": "Conta de sen ler",
|
||||
"form.submit.loading": "Cargando…",
|
||||
"form.submit.saving": "Gardando…",
|
||||
"form.user.label.admin": "Admin",
|
||||
"form.user.label.confirmation": "Confirmar contrasinal",
|
||||
"form.user.label.password": "Contrasinal",
|
||||
"form.user.label.username": "Identificador",
|
||||
"menu.about": "Sobre",
|
||||
"menu.add_feed": "Engadir canle",
|
||||
"menu.add_user": "Engadir usuaria",
|
||||
"menu.api_keys": "Claves da API",
|
||||
"menu.categories": "Categorías",
|
||||
"menu.create_api_key": "Crear nova clave da API",
|
||||
"menu.create_category": "Crear unha categoría",
|
||||
"menu.edit_category": "Editar",
|
||||
"menu.edit_feed": "Editar",
|
||||
"menu.export": "Exportar",
|
||||
"menu.feed_entries": "Entradas",
|
||||
"menu.feeds": "Canles",
|
||||
"menu.flush_history": "Eliminar historial",
|
||||
"menu.history": "Historial",
|
||||
"menu.home_page": "Páxina de inicio",
|
||||
"menu.import": "Importar",
|
||||
"menu.integrations": "Integracións",
|
||||
"menu.logout": "Fechar sesión",
|
||||
"menu.mark_all_as_read": "Marca todo como lido",
|
||||
"menu.mark_page_as_read": "Marca esta páxina como lida",
|
||||
"menu.preferences": "Preferencias",
|
||||
"menu.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
|
||||
"menu.refresh_feed": "Actualizar",
|
||||
"menu.search": "Buscar",
|
||||
"menu.sessions": "Sesións",
|
||||
"menu.settings": "Axustes",
|
||||
"menu.shared_entries": "Entradas compartidas",
|
||||
"menu.show_all_entries": "Motrar todas as entradas",
|
||||
"menu.show_only_starred_entries": "Mostrar só entradas con estrela",
|
||||
"menu.show_only_unread_entries": "Mostrar só entradas sen ler",
|
||||
"menu.starred": "Con estrela",
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "Sen ler",
|
||||
"menu.users": "Usuarias",
|
||||
"page.about.author": "Autoría:",
|
||||
"page.about.build_date": "Data da versión:",
|
||||
"page.about.credits": "Crédito",
|
||||
"page.about.db_usage": "Tamaño da BDD:",
|
||||
"page.about.git_commit": "Git Commit:",
|
||||
"page.about.global_config_options": "Configuración global",
|
||||
"page.about.go_version": "Versión de Go:",
|
||||
"page.about.license": "Licenza:",
|
||||
"page.about.postgres_version": "Versión de Postgres:",
|
||||
"page.about.title": "Sobre",
|
||||
"page.about.version": "Versión:",
|
||||
"page.add_feed.choose_feed": "Elixe unha canle",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.legend.advanced_options": "Opcións avanzadas",
|
||||
"page.add_feed.no_category": "Non hai categoría. Tes que ter polo menos unha categoría.",
|
||||
"page.add_feed.submit": "Atopa unha canle",
|
||||
"page.add_feed.title": "Nova canle",
|
||||
"page.api_keys.never_used": "Nunca utilizado",
|
||||
"page.api_keys.table.actions": "Accións",
|
||||
"page.api_keys.table.created_at": "Data de creación",
|
||||
"page.api_keys.table.description": "Descrición",
|
||||
"page.api_keys.table.last_used_at": "Último uso",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.title": "Claves da API",
|
||||
"page.categories.entries": "Entradas",
|
||||
"page.categories.feed_count": [
|
||||
"Hai %d canle.",
|
||||
"Hai %d canles."
|
||||
],
|
||||
"page.categories.feeds": "Canles",
|
||||
"page.categories.no_feed": "No hai canle.",
|
||||
"page.categories.title": "Categorías",
|
||||
"page.categories_count": [
|
||||
"%d categoría",
|
||||
"%d categorías"
|
||||
],
|
||||
"page.category_label": "Categoría: %s",
|
||||
"page.edit_category.title": "Editar categoría: %s",
|
||||
"page.edit_feed.etag_header": "Cabeceira ETag:",
|
||||
"page.edit_feed.last_check": "Última comprobación:",
|
||||
"page.edit_feed.last_modified_header": "Cabeceira LastModified:",
|
||||
"page.edit_feed.last_parsing_error": "Erro Last Parsing",
|
||||
"page.edit_feed.no_header": "Ningún",
|
||||
"page.edit_feed.title": "Editar canle: %s",
|
||||
"page.edit_user.title": "Editar usuaria: %s",
|
||||
"page.entry.attachments": "Anexos",
|
||||
"page.feeds.error_count": [
|
||||
"%d erro",
|
||||
"%d erros"
|
||||
],
|
||||
"page.feeds.last_check": "Última comprobación:",
|
||||
"page.feeds.next_check": "Próxima comprobación:",
|
||||
"page.feeds.read_counter": "Número de entradas lidas",
|
||||
"page.feeds.title": "Canles",
|
||||
"page.footer.elevator": "Volver arriba",
|
||||
"page.history.title": "Historial",
|
||||
"page.import.title": "Importar",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet.help": "Esta é unha ligazón especial que che permite subscribirte a unha web directamente usando un marcador no teu navegador.",
|
||||
"page.integration.bookmarklet.instructions": "Arrastra e solta esta ligazón nos teus marcadores.",
|
||||
"page.integration.bookmarklet.name": "Engadir a Miniflux",
|
||||
"page.integration.miniflux_api": "API de Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Punto de acceso da API",
|
||||
"page.integration.miniflux_api_password": "Contrasinal",
|
||||
"page.integration.miniflux_api_password_value": "Contrasinal da túa conta",
|
||||
"page.integration.miniflux_api_username": "Identificador",
|
||||
"page.integrations.title": "Integracións",
|
||||
"page.keyboard_shortcuts.close_modal": "Fechar diálogo modal",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar contido orixinal",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ir ao elemento de abaixo de todo",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ir a categorías",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ir á canle",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Ir ás canles",
|
||||
"page.keyboard_shortcuts.go_to_history": "Ir ao historial",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Ir ao seguinte elemento",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Ir á páxina seguinte",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Ir ao elemento anterior",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Ir á páxina anterior",
|
||||
"page.keyboard_shortcuts.go_to_search": "Pór o foco no formulario de busca",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Ir aos axustes",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Ir a artigos con estrela",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ir ao elemento de arriba de todo",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Ir aos non lidos",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marcar páxina actual como lida",
|
||||
"page.keyboard_shortcuts.open_comments": "Abrir ligazón de comentarios",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Abrir ligazón de comentarios na pestana actual",
|
||||
"page.keyboard_shortcuts.open_item": "Abrir elemento seleccionado",
|
||||
"page.keyboard_shortcuts.open_original": "Abrir ligazón orixinal",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Abrir ligazón orixinal na pestana actual",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Actualizar en segundo plano todas as canles",
|
||||
"page.keyboard_shortcuts.remove_feed": "Retirar esta canle",
|
||||
"page.keyboard_shortcuts.save_article": "Gardar entrada",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Desprazar o elemento arriba de todo",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Mostrar atallos do teclado",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Accións",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Moverse polos elementos",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Moverse polas páxinas",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Moverse polas seccións",
|
||||
"page.keyboard_shortcuts.title": "Atallos do teclado",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "Pór/quitar estrela",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Cambiar abrir/fechar anexos da entrada",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Cambiar lido/non lido, foco na seguinte",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambiar lido/non lido, foco na anterior",
|
||||
"page.login.google_signin": "Acceder con Google",
|
||||
"page.login.oidc_signin": "Acceder con %s",
|
||||
"page.login.title": "Acceder",
|
||||
"page.login.webauthn_login": "Acceso con clave de paso",
|
||||
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
|
||||
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha clave de seguridade. Non se require isto se estás a usar unha «Passkey» (credenciais descubribles).",
|
||||
"page.new_api_key.title": "Nova clave da API",
|
||||
"page.new_category.title": "Nova Categoría",
|
||||
"page.new_user.title": "Nova Usuaria",
|
||||
"page.offline.message": "Non tes conexión",
|
||||
"page.offline.refresh_page": "Intenta actualizar a páxina",
|
||||
"page.offline.title": "Modo sen conexión",
|
||||
"page.read_entry_count": [
|
||||
"%d entrada lida",
|
||||
"%d entradas lidas"
|
||||
],
|
||||
"page.search.title": "Resultados da busca",
|
||||
"page.sessions.table.actions": "Accións",
|
||||
"page.sessions.table.current_session": "Sesión actual",
|
||||
"page.sessions.table.date": "Data",
|
||||
"page.sessions.table.ip": "Enderezo IP",
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "Sesións",
|
||||
"page.settings.link_google_account": "Ligar coa miña conta Google",
|
||||
"page.settings.link_oidc_account": "Ligar coa miña conta %s",
|
||||
"page.settings.title": "Axustes",
|
||||
"page.settings.unlink_google_account": "Desligar da miña conta Google",
|
||||
"page.settings.unlink_oidc_account": "Desligar da miña conta %s",
|
||||
"page.settings.webauthn.actions": "Accións",
|
||||
"page.settings.webauthn.added_on": "Engadida o",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Retirar %d passkey",
|
||||
"Retirar %d passkeys"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Último uso",
|
||||
"page.settings.webauthn.passkey_name": "Nome da Passkey",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.register": "Rexistrar Passkey",
|
||||
"page.settings.webauthn.register.error": "Non se puido rexistrar Passkey",
|
||||
"page.shared_entries.title": "Entradas compartidas",
|
||||
"page.shared_entries_count": [
|
||||
"%d entrada compartida",
|
||||
"%d entradas compartidas"
|
||||
],
|
||||
"page.starred.title": "Con estrela",
|
||||
"page.starred_entry_count": [
|
||||
"%d entrada con estrela",
|
||||
"%d entradas con estrela"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entrada en total",
|
||||
"%d entradas en total"
|
||||
],
|
||||
"page.unread.title": "Sen ler",
|
||||
"page.unread_entry_count": [
|
||||
"%d entrada sen ler",
|
||||
"%d entradas sen ler"
|
||||
],
|
||||
"page.users.actions": "Accións",
|
||||
"page.users.admin.no": "Non",
|
||||
"page.users.admin.yes": "Si",
|
||||
"page.users.is_admin": "Administración",
|
||||
"page.users.last_login": "Último acceso",
|
||||
"page.users.never_logged": "Nunca",
|
||||
"page.users.title": "Usuarias",
|
||||
"page.users.username": "Identificador",
|
||||
"page.webauthn_rename.title": "Cambiar nome a Passkey",
|
||||
"pagination.first": "Primeiro",
|
||||
"pagination.last": "Último",
|
||||
"pagination.next": "Seguinte",
|
||||
"pagination.previous": "Anterior",
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Buscar…",
|
||||
"search.submit": "Buscar",
|
||||
"skip_to_content": "Ir ao contido",
|
||||
"time_elapsed.days": [
|
||||
"hai %d día",
|
||||
"hai %d días"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"hai %d hora",
|
||||
"hai %d horas"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"hai %d minuto",
|
||||
"hai %d minutos"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"hai %d mes",
|
||||
"hai %d meses"
|
||||
],
|
||||
"time_elapsed.not_yet": "aínda non",
|
||||
"time_elapsed.now": "xusto agora",
|
||||
"time_elapsed.weeks": [
|
||||
"hai %d semana",
|
||||
"hai %d semanas"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"hai %d ano",
|
||||
"hai %d anos"
|
||||
],
|
||||
"time_elapsed.yesterday": "onte",
|
||||
"tooltip.keyboard_shortcuts": "Atallo do teclado: %s",
|
||||
"tooltip.logged_user": "Sesión de %s"
|
||||
}
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "श्रेणी",
|
||||
"form.feed.label.cookie": "कुकीज़ सेट करें",
|
||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "विवरण",
|
||||
"form.feed.label.disable_http2": "फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें",
|
||||
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"form.feed.label.category": "Kategori",
|
||||
"form.feed.label.cookie": "Atur Kuki",
|
||||
"form.feed.label.crawler": "Ambil konten asli",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Deskripsi",
|
||||
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
|
||||
"form.feed.label.disabled": "Jangan perbarui umpan ini",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.cookie": "Installare i cookies",
|
||||
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Descrizione",
|
||||
"form.feed.label.disable_http2": "Disabilita HTTP/2 per evitare il fingerprinting",
|
||||
"form.feed.label.disabled": "Non aggiornare questo feed",
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"form.feed.label.category": "カテゴリ",
|
||||
"form.feed.label.cookie": "Cookie の設定",
|
||||
"form.feed.label.crawler": "オリジナルの内容を取得",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "説明",
|
||||
"form.feed.label.disable_http2": "フィンガープリンティング回避のため HTTP/2 を無効化",
|
||||
"form.feed.label.disabled": "このフィードを更新しない",
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"form.feed.label.category": "lūi-pia̍t",
|
||||
"form.feed.label.cookie": "Siat-tēng Cookies",
|
||||
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Biâu-su̍t",
|
||||
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
|
||||
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.cookie": "Cookies instellen",
|
||||
"form.feed.label.crawler": "Download originele inhoud",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Omschrijving",
|
||||
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
|
||||
"form.feed.label.disabled": "Deze feed niet vernieuwen",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.cookie": "Ustaw ciasteczka",
|
||||
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
||||
"form.feed.label.ignore_entry_updates": "Ignoruj aktualizacje wpisów",
|
||||
"form.feed.label.description": "Opis",
|
||||
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
|
||||
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.cookie": "Definir Cookies",
|
||||
"form.feed.label.crawler": "Obter conteúdo original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Descrição",
|
||||
"form.feed.label.disable_http2": "Desativar HTTP/2 para evitar fingerprinting",
|
||||
"form.feed.label.disabled": "Não atualizar esta fonte",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.cookie": "Setare Cookie-uri",
|
||||
"form.feed.label.crawler": "Aduce conținutul original",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Descriere",
|
||||
"form.feed.label.disable_http2": "Dezactivează HTTP/2 pentru a preveni amprentarea",
|
||||
"form.feed.label.disabled": "Nu actualiza acest flux",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"form.feed.label.category": "Категория",
|
||||
"form.feed.label.cookie": "Установить куки",
|
||||
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Описание",
|
||||
"form.feed.label.disable_http2": "Отключить HTTP/2 для предотвращения фингерпринтинга",
|
||||
"form.feed.label.disabled": "Не обновлять эту подписку",
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
"form.feed.label.category": "Kategori",
|
||||
"form.feed.label.cookie": "Çerezleri Ayarla",
|
||||
"form.feed.label.crawler": "Orijinal içeriği çek",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Açıklama",
|
||||
"form.feed.label.disable_http2": "Parmak izini önlemek için HTTP/2'yi devre dışı bırakın",
|
||||
"form.feed.label.disabled": "Bu beslemeyi yenileme",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"form.feed.label.category": "Категорія",
|
||||
"form.feed.label.cookie": "Встановити кукі",
|
||||
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.description": "Опис",
|
||||
"form.feed.label.disable_http2": "Вимкнути HTTP/2 для уникнення відбитків",
|
||||
"form.feed.label.disabled": "Не оновлювати цю стрічку",
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"form.feed.label.category": "分类",
|
||||
"form.feed.label.cookie": "设置 Cookie",
|
||||
"form.feed.label.crawler": "获取原始内容",
|
||||
"form.feed.label.ignore_entry_updates": "忽略条目更新",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.disable_http2": "禁用 HTTP/2 以避免指纹识别",
|
||||
"form.feed.label.disabled": "不刷新此订阅",
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"form.feed.label.category": "類別",
|
||||
"form.feed.label.cookie": "設定 Cookies",
|
||||
"form.feed.label.crawler": "下載原文內容",
|
||||
"form.feed.label.ignore_entry_updates": "忽略條目更新",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.disable_http2": "停用 HTTP/2 以避免指紋追蹤",
|
||||
"form.feed.label.disabled": "不要更新此 Feed",
|
||||
|
||||
@@ -55,6 +55,7 @@ type Feed struct {
|
||||
PushoverEnabled bool `json:"pushover_enabled"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
@@ -156,6 +157,7 @@ type FeedCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
NoMediaPlayer bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
@@ -195,6 +197,7 @@ type FeedModificationRequest struct {
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
@@ -260,6 +263,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
|
||||
feed.Crawler = *f.Crawler
|
||||
}
|
||||
|
||||
if f.IgnoreEntryUpdates != nil {
|
||||
feed.IgnoreEntryUpdates = *f.IgnoreEntryUpdates
|
||||
}
|
||||
|
||||
if f.UserAgent != nil {
|
||||
feed.UserAgent = *f.UserAgent
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user