Compare commits
46 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 |
@@ -24,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-*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,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
|
||||
@@ -37,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
|
||||
@@ -51,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 }}
|
||||
@@ -73,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: .
|
||||
@@ -90,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+7
-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"`
|
||||
@@ -357,6 +360,8 @@ type APIKeyCreationRequest struct {
|
||||
}
|
||||
|
||||
// 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.11.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.8
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/term v0.40.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.41.0 // indirect
|
||||
golang.org/x/text v0.34.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=
|
||||
@@ -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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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)
|
||||
|
||||
+15
-12
@@ -219,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",
|
||||
@@ -293,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,
|
||||
@@ -353,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",
|
||||
@@ -791,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 {
|
||||
@@ -847,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
|
||||
}
|
||||
|
||||
@@ -1351,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,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()
|
||||
|
||||
|
||||
@@ -1427,4 +1427,8 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
@@ -1102,27 +1106,11 @@ 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})
|
||||
}
|
||||
|
||||
@@ -1139,28 +1127,11 @@ 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})
|
||||
}
|
||||
|
||||
@@ -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,15 +1170,13 @@ 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) {
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
entryURL := "https://example.com/article"
|
||||
entryTitle := "Example Title"
|
||||
entryContent := "<p>Some HTML content</p>"
|
||||
@@ -98,11 +102,11 @@ func TestCreateBookmark(t *testing.T) {
|
||||
if !strings.HasPrefix(ct, "multipart/form-data;") {
|
||||
t.Errorf("expected multipart/form-data, got %s", ct)
|
||||
}
|
||||
boundaryIdx := strings.Index(ct, "boundary=")
|
||||
if boundaryIdx == -1 {
|
||||
_, after, ok := strings.Cut(ct, "boundary=")
|
||||
if !ok {
|
||||
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
|
||||
}
|
||||
boundary := ct[boundaryIdx+len("boundary="):]
|
||||
boundary := after
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
|
||||
seenLabels := []string{}
|
||||
@@ -132,12 +136,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
case "resource":
|
||||
// First line is JSON header, then newline, then content
|
||||
all := string(data)
|
||||
idx := strings.IndexByte(all, '\n')
|
||||
if idx == -1 {
|
||||
before, after, ok := strings.Cut(all, "\n")
|
||||
if !ok {
|
||||
t.Fatalf("resource content missing header separator")
|
||||
}
|
||||
headerJSON := all[:idx]
|
||||
resourceBody = all[idx+1:]
|
||||
headerJSON := before
|
||||
resourceBody = after
|
||||
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
|
||||
t.Fatalf("invalid resource header JSON: %v", err)
|
||||
}
|
||||
@@ -258,3 +262,21 @@ func TestNewClient(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func OptionalString(value string) *string {
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:fix inline
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type UserSession struct {
|
||||
}
|
||||
|
||||
func (u *UserSession) String() string {
|
||||
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
|
||||
return fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
|
||||
}
|
||||
|
||||
// UseTimezone converts creation date to the given timezone.
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
func TestCharsetReaderWithUTF8(t *testing.T) {
|
||||
@@ -346,3 +348,146 @@ func TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithKOI8RLabel(t *testing.T) {
|
||||
expectedUnicodeString := "Привет мир"
|
||||
|
||||
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to build KOI8-R input: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("koi8-r", bytes.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
if string(data) != expectedUnicodeString {
|
||||
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithUppercaseKOI8RLabel(t *testing.T) {
|
||||
expectedUnicodeString := "Привет мир"
|
||||
|
||||
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to build KOI8-R input: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("KOI8-R", bytes.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
if string(data) != expectedUnicodeString {
|
||||
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithKOI8RFeedFixture(t *testing.T) {
|
||||
file := "testdata/koi8r.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("KOI8-R", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
if !bytes.Contains(data, []byte("Пример RSS ленты")) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", "Пример RSS ленты")
|
||||
}
|
||||
|
||||
if !bytes.Contains(data, []byte("Привет мир! Ёжик, чай, Москва, Санкт-Петербург.")) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", "Привет мир! Ёжик, чай, Москва, Санкт-Петербург.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCharsetReaderWithKOI8RContentType(t *testing.T) {
|
||||
expectedUnicodeString := "Привет мир"
|
||||
|
||||
input, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to build KOI8-R input: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(bytes.NewReader(input), "text/xml; charset=koi8-r")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
if string(data) != expectedUnicodeString {
|
||||
t.Fatalf("Data does not match expected unicode string, got %q expected %q", string(data), expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCharsetReaderWithKOI8RFeedFixtureAndContentType(t *testing.T) {
|
||||
file := "testdata/koi8r.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "application/rss+xml; charset=KOI8-R")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
if !bytes.Contains(data, []byte("Тестовая лента в кодировке KOI8-R")) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", "Тестовая лента в кодировке KOI8-R")
|
||||
}
|
||||
|
||||
if !bytes.Contains(data, []byte("Проверка специальных символов")) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", "Проверка специальных символов")
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="KOI8-R"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>ðÒÉÍÅÒ RSS ÌÅÎÔÙ</title>
|
||||
<link>http://example.com/</link>
|
||||
<description>ôÅÓÔÏ×ÁÑ ÌÅÎÔÁ × ËÏÄÉÒÏ×ËÅ KOI8-R</description>
|
||||
<language>ru</language>
|
||||
<lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>
|
||||
|
||||
<item>
|
||||
<title>ðÅÒ×ÁÑ ÎÏ×ÏÓÔØ</title>
|
||||
<link>http://example.com/post1</link>
|
||||
<guid>http://example.com/post1</guid>
|
||||
<pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>
|
||||
<description>
|
||||
üÔÏ ÔÅÓÔÏ×ÏÅ ÏÐÉÓÁÎÉÅ Ó ÒÕÓÓËÉÍÉ ÓÉÍ×ÏÌÁÍÉ:
|
||||
ðÒÉ×ÅÔ ÍÉÒ! ³ÖÉË, ÞÁÊ, íÏÓË×Á, óÁÎËÔ-ðÅÔÅÒÂÕÒÇ.
|
||||
</description>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>÷ÔÏÒÁÑ ÎÏ×ÏÓÔØ</title>
|
||||
<link>http://example.com/post2</link>
|
||||
<guid>http://example.com/post2</guid>
|
||||
<pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>
|
||||
<description>
|
||||
ðÒÏ×ÅÒËÁ ÓÐÅÃÉÁÌØÎÙÈ ÓÉÍ×ÏÌÏ×: & < >
|
||||
á ÔÁËÖÅ ÞÉÓÌÁ: 1234567890.
|
||||
</description>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -6,20 +6,29 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPClientTimeout = 20 * time.Second
|
||||
defaultAcceptHeader = "application/xml, application/atom+xml, application/rss+xml, application/rdf+xml, application/feed+json, text/html, */*;q=0.9"
|
||||
defaultAcceptHeader = "application/xml,application/atom+xml,application/rss+xml,application/rdf+xml,application/feed+json,text/html,*/*;q=0.9"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHostnameResolution = errors.New("fetcher: unable to resolve request hostname")
|
||||
ErrPrivateNetworkHost = errors.New("fetcher: refusing to access private network host")
|
||||
)
|
||||
|
||||
type RequestBuilder struct {
|
||||
@@ -130,16 +139,39 @@ func (r *RequestBuilder) WithoutCompression() *RequestBuilder {
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second, // Default is 30s.
|
||||
KeepAlive: 15 * time.Second, // Default is 30s.
|
||||
}
|
||||
|
||||
// Perform the private-network check inside the dialer's Control callback,
|
||||
// which fires after DNS resolution but before the TCP connection is made.
|
||||
// This eliminates TOCTOU / DNS-rebinding vulnerabilities: the resolved IP
|
||||
// that is checked is exactly the IP that will be connected to.
|
||||
allowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()
|
||||
if !allowPrivateNetworks {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if urllib.IsNonPublicIP(ip) {
|
||||
return fmt.Errorf("%w %q", ErrPrivateNetworkHost, host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
||||
ForceAttemptHTTP2: true,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second, // Default is 30s.
|
||||
KeepAlive: 15 * time.Second, // Default is 30s.
|
||||
}).DialContext,
|
||||
MaxIdleConns: 50, // Default is 100.
|
||||
IdleConnTimeout: 10 * time.Second, // Default is 90s.
|
||||
DialContext: dialer.DialContext,
|
||||
MaxIdleConns: 50, // Default is 100.
|
||||
IdleConnTimeout: 10 * time.Second, // Default is 90s.
|
||||
}
|
||||
|
||||
if r.ignoreTLSErrors {
|
||||
@@ -205,7 +237,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
if r.disableCompression {
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
} else {
|
||||
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||
req.Header.Set("Accept-Encoding", "br,gzip")
|
||||
}
|
||||
|
||||
// Set default Accept header if not already set.
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestNewRequestBuilder(t *testing.T) {
|
||||
@@ -300,8 +303,8 @@ func TestRequestBuilder_WithoutCompression(t *testing.T) {
|
||||
|
||||
func TestRequestBuilder_WithCompression(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept-Encoding") != "br, gzip" {
|
||||
t.Errorf("Expected Accept-Encoding to be 'br, gzip', got '%s'", r.Header.Get("Accept-Encoding"))
|
||||
if r.Header.Get("Accept-Encoding") != "br,gzip" {
|
||||
t.Errorf("Expected Accept-Encoding to be 'br,gzip', got '%s'", r.Header.Get("Accept-Encoding"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
@@ -398,6 +401,69 @@ func TestRequestBuilder_InvalidURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_RefusePrivateNetworkByDefault(t *testing.T) {
|
||||
configureFetcherAllowPrivateNetworksOption(t, "0")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
builder := NewRequestBuilder()
|
||||
_, err := builder.ExecuteRequest(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected private network request to be rejected")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "refusing to access private network host") {
|
||||
t.Fatalf("Unexpected error for private network request: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {
|
||||
configureFetcherAllowPrivateNetworksOption(t, "1")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
builder := NewRequestBuilder()
|
||||
resp, err := builder.ExecuteRequest(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected private network request to succeed when enabled: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
|
||||
configureFetcherAllowPrivateNetworksOption(t, "0")
|
||||
|
||||
// Target server on a loopback address (private).
|
||||
privateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer privateServer.Close()
|
||||
|
||||
// Redirector that sends the client to the private server.
|
||||
// Because the Control callback checks the IP at connection time, the
|
||||
// redirect target is also validated (unlike a pre-flight DNS check).
|
||||
redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, privateServer.URL, http.StatusFound)
|
||||
}))
|
||||
defer redirectServer.Close()
|
||||
|
||||
builder := NewRequestBuilder()
|
||||
_, err := builder.ExecuteRequest(redirectServer.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected redirect to private network to be rejected")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "refusing to access private network host") {
|
||||
t.Fatalf("Unexpected error for redirected private network request: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
|
||||
// Create a slow server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -420,3 +486,21 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
|
||||
t.Errorf("Expected timeout around 1s, took %v", duration)
|
||||
}
|
||||
}
|
||||
|
||||
func configureFetcherAllowPrivateNetworksOption(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("FETCHER_ALLOW_PRIVATE_NETWORKS", value)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,8 +238,7 @@ func containsRegexPattern(pattern string, items []string) bool {
|
||||
func parseDuration(duration string) (time.Duration, error) {
|
||||
// Handle common duration formats like "30d", "7d", "1h", "1m", etc.
|
||||
// Go's time.ParseDuration doesn't support days, so we handle them manually
|
||||
if strings.HasSuffix(duration, "d") {
|
||||
daysStr := strings.TrimSuffix(duration, "d")
|
||||
if daysStr, ok := strings.CutSuffix(duration, "d"); ok {
|
||||
days := 0
|
||||
if daysStr != "" {
|
||||
var err error
|
||||
|
||||
@@ -27,6 +27,16 @@ var (
|
||||
ErrDuplicatedFeed = errors.New("fetcher: duplicated feed")
|
||||
)
|
||||
|
||||
func getTranslatedLocalizedError(store *storage.Storage, userID int64, originalFeed *model.Feed, localizedError *locale.LocalizedErrorWrapper) *locale.LocalizedErrorWrapper {
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
}
|
||||
|
||||
func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequestFromSubscriptionDiscovery) (*model.Feed, *locale.LocalizedErrorWrapper) {
|
||||
slog.Debug("Begin feed creation process from subscription discovery",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -53,6 +63,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
subscription.Username = feedCreationRequest.Username
|
||||
subscription.Password = feedCreationRequest.Password
|
||||
subscription.Crawler = feedCreationRequest.Crawler
|
||||
subscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates
|
||||
subscription.Disabled = feedCreationRequest.Disabled
|
||||
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
|
||||
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
|
||||
@@ -154,6 +165,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.Username = feedCreationRequest.Username
|
||||
subscription.Password = feedCreationRequest.Password
|
||||
subscription.Crawler = feedCreationRequest.Crawler
|
||||
subscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates
|
||||
subscription.Disabled = feedCreationRequest.Disabled
|
||||
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
|
||||
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
|
||||
@@ -209,7 +221,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
weeklyEntryCount := 0
|
||||
var refreshDelay time.Duration
|
||||
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
|
||||
var weeklyCountErr error
|
||||
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
|
||||
@@ -219,7 +230,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
originalFeed.CheckedNow()
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||
@@ -249,7 +260,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Warn("Feed is rate limited",
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Int("retry_delay_in_seconds", int(retryDelay.Seconds())),
|
||||
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
|
||||
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
@@ -262,24 +272,12 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Any("error", localizedError.Error()),
|
||||
)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
|
||||
}
|
||||
|
||||
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
|
||||
}
|
||||
|
||||
if ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
|
||||
@@ -299,18 +297,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
updatedFeed, parseErr := parser.ParseFeed(responseHandler.EffectiveURL(), bytes.NewReader(responseBody))
|
||||
if parseErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(parseErr, "error.unable_to_parse_feed", parseErr)
|
||||
|
||||
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
|
||||
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
|
||||
}
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
|
||||
}
|
||||
|
||||
// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.
|
||||
@@ -318,7 +308,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
feedTTLValue := updatedFeed.TTL
|
||||
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()
|
||||
expiresValue := responseHandler.Expires()
|
||||
refreshDelay = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
|
||||
refreshDelay := max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
|
||||
|
||||
// Set the next check at with updated arguments.
|
||||
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
|
||||
@@ -338,18 +328,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
originalFeed.Entries = updatedFeed.Entries
|
||||
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
|
||||
|
||||
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
|
||||
updateExistingEntries := forceRefresh || !originalFeed.Crawler
|
||||
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries).
|
||||
// We also skip updating existing entries if the feed has ignore_entry_updates enabled.
|
||||
// Unless it is forced to refresh.
|
||||
updateExistingEntries := forceRefresh || (!originalFeed.Crawler && !originalFeed.IgnoreEntryUpdates)
|
||||
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
|
||||
if storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
|
||||
}
|
||||
|
||||
userIntegrations, intErr := store.Integration(userID)
|
||||
@@ -390,13 +376,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
return getTranslatedLocalizedError(store, userID, originalFeed, localizedError)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *iconChecker) fetchAndStoreIcon() {
|
||||
func (c *iconChecker) UpdateOrCreateFeedIcon() {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(c.feed.Cookie)
|
||||
@@ -79,9 +79,5 @@ func (c *iconChecker) CreateFeedIconIfMissing() {
|
||||
return
|
||||
}
|
||||
|
||||
c.fetchAndStoreIcon()
|
||||
}
|
||||
|
||||
func (c *iconChecker) UpdateOrCreateFeedIcon() {
|
||||
c.fetchAndStoreIcon()
|
||||
c.UpdateOrCreateFeedIcon()
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
|
||||
|
||||
// Try the website URL first, then fall back to the root URL if no icon is found.
|
||||
// The website URL may include a subdirectory (e.g., https://example.org/subfolder/), and icons can be referenced relative to that path.
|
||||
for _, documentURL := range []string{f.websiteURL, urllib.RootURL(f.websiteURL)} {
|
||||
urls := []string{f.websiteURL}
|
||||
if rootURL := urllib.RootURL(f.websiteURL); rootURL != urls[0] {
|
||||
urls = []string{f.websiteURL, rootURL}
|
||||
}
|
||||
for _, documentURL := range urls {
|
||||
if icon, err := f.fetchIconsFromHTMLDocument(documentURL); err != nil {
|
||||
slog.Debug("Unable to fetch icons from HTML document",
|
||||
slog.String("document_url", documentURL),
|
||||
@@ -156,10 +160,6 @@ func (f *iconFinder) downloadIcon(iconURL string) (*model.Icon, error) {
|
||||
slog.String("icon_url", iconURL),
|
||||
)
|
||||
|
||||
if err := ensureRemoteIconURLAllowed(iconURL, config.Opts.IconFetchAllowPrivateNetworks()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(iconURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
@@ -257,34 +257,30 @@ func findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentTyp
|
||||
return nil, fmt.Errorf("icon: unable to read document: %v", err)
|
||||
}
|
||||
|
||||
queries := [...]string{
|
||||
"link[rel='icon' i][href]",
|
||||
"link[rel='shortcut icon' i][href]",
|
||||
"link[rel='icon shortcut' i][href]",
|
||||
"link[rel='apple-touch-icon'][href]",
|
||||
}
|
||||
query := `link[rel='icon' i][href],
|
||||
link[rel='shortcut icon' i][href],
|
||||
link[rel='icon shortcut' i][href],
|
||||
link[rel='apple-touch-icon'][href]`
|
||||
|
||||
var iconURLs []string
|
||||
for _, query := range queries {
|
||||
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
|
||||
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
|
||||
|
||||
for _, s := range doc.Find(query).EachIter() {
|
||||
href, _ := s.Attr("href")
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
for _, s := range doc.Find(query).EachIter() {
|
||||
href, _ := s.Attr("href")
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {
|
||||
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
|
||||
} else {
|
||||
iconURLs = append(iconURLs, absoluteIconURL)
|
||||
slog.Debug("Found icon URL in HTML document",
|
||||
slog.String("query", query),
|
||||
slog.String("icon_href", href),
|
||||
slog.String("absolute_icon_url", absoluteIconURL),
|
||||
)
|
||||
}
|
||||
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {
|
||||
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
|
||||
} else {
|
||||
iconURLs = append(iconURLs, absoluteIconURL)
|
||||
slog.Debug("Found icon URL in HTML document",
|
||||
slog.String("query", query),
|
||||
slog.String("icon_href", href),
|
||||
slog.String("absolute_icon_url", absoluteIconURL),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,39 +330,3 @@ func parseImageDataURL(value string) (*model.Icon, error) {
|
||||
MimeType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureRemoteIconURLAllowed(iconURL string, allowPrivateNetworks bool) error {
|
||||
parsedURL, err := url.Parse(iconURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("icon: invalid icon URL %q: %w", iconURL, err)
|
||||
}
|
||||
|
||||
if !parsedURL.IsAbs() {
|
||||
return fmt.Errorf("icon: icon URL %q must be absolute", iconURL)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf("icon: unsupported icon URL scheme %q", parsedURL.Scheme)
|
||||
}
|
||||
|
||||
hostname := parsedURL.Hostname()
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("icon: icon URL %q has no hostname", iconURL)
|
||||
}
|
||||
|
||||
if allowPrivateNetworks {
|
||||
return nil
|
||||
}
|
||||
|
||||
isPrivate, err := urllib.ResolvesToPrivateIP(hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("icon: unable to resolve icon hostname %q: %w", hostname, err)
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
return fmt.Errorf("icon: refusing to download icon from private network host %q", hostname)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -246,12 +246,10 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The function processes queries in order: rel="icon", then rel="shortcut icon", etc.
|
||||
// So both rel="icon" links are found first, then the rel="shortcut icon" link
|
||||
expected := []string{
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==",
|
||||
"https://example.org/regular-icon.ico",
|
||||
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>",
|
||||
"https://example.org/regular-icon.ico",
|
||||
}
|
||||
|
||||
if len(iconURLs) != len(expected) {
|
||||
@@ -400,24 +398,6 @@ func TestResizeIconWebp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRemoteIconURLAllowedRejectsPrivateNetworks(t *testing.T) {
|
||||
if err := ensureRemoteIconURLAllowed("http://192.168.0.1/favicon.ico", false); err == nil {
|
||||
t.Fatal("Expected private network hosts to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRemoteIconURLAllowedAllowsPublicNetworks(t *testing.T) {
|
||||
if err := ensureRemoteIconURLAllowed("https://1.1.1.1/favicon.ico", false); err != nil {
|
||||
t.Fatalf("Expected public network hosts to be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRemoteIconURLAllowedAllowsPrivateWhenEnabled(t *testing.T) {
|
||||
if err := ensureRemoteIconURLAllowed("http://10.0.0.5/icon.png", true); err != nil {
|
||||
t.Fatalf("Expected private network hosts to be allowed when explicitly enabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeInvalidImage(t *testing.T) {
|
||||
icon := model.Icon{
|
||||
Content: []byte("invalid data"),
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
FormatUnknown = "unknown"
|
||||
)
|
||||
|
||||
const maxTokensToConsider = uint(50)
|
||||
|
||||
// DetectFeedFormat tries to guess the feed format from input data.
|
||||
func DetectFeedFormat(r io.ReadSeeker) (string, string) {
|
||||
r.Seek(0, io.SeekStart)
|
||||
@@ -32,11 +34,13 @@ func DetectFeedFormat(r io.ReadSeeker) (string, string) {
|
||||
r.Seek(0, io.SeekStart)
|
||||
decoder := rxml.NewXMLDecoder(r)
|
||||
|
||||
processedTokens := uint(0)
|
||||
for {
|
||||
token, _ := decoder.Token()
|
||||
if token == nil {
|
||||
if token == nil || processedTokens == maxTokensToConsider {
|
||||
break
|
||||
}
|
||||
processedTokens += 1
|
||||
|
||||
if element, ok := token.(xml.StartElement); ok {
|
||||
switch element.Name.Local {
|
||||
|
||||
@@ -80,6 +80,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("filter_stage", "before_scrape"),
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -92,6 +93,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
webpageBaseURL := ""
|
||||
entry.URL = rewrite.RewriteEntryURL(feed, entry)
|
||||
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
|
||||
contentExtractedSuccessfully := false
|
||||
if feed.Crawler && (entryIsNew || forceRefresh) {
|
||||
slog.Debug("Scraping entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
@@ -135,11 +137,26 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
} else if extractedContent != "" {
|
||||
// We replace the entry content only if the scraper doesn't return any error.
|
||||
entry.Content = minifyContent(extractedContent)
|
||||
contentExtractedSuccessfully = true
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.ApplyContentRewriteRules(entry, feed.RewriteRules)
|
||||
|
||||
// Re-run filters only when extracted content replaced entry.Content.
|
||||
if contentExtractedSuccessfully && filter.IsBlockedEntry(blockRules, allowRules, feed, entry) {
|
||||
slog.Debug("Entry is blocked by filter rules",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_hash", entry.Hash),
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("filter_stage", "after_scrape"),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if webpageBaseURL == "" {
|
||||
webpageBaseURL = entry.URL
|
||||
}
|
||||
|
||||
@@ -173,13 +173,20 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
|
||||
if append {
|
||||
html, _ := s.Html()
|
||||
output.WriteString("<" + tag + ">" + html + "</" + tag + ">")
|
||||
output.WriteByte('<')
|
||||
output.WriteString(tag)
|
||||
output.WriteByte('>')
|
||||
output.WriteString(html)
|
||||
output.WriteString("</")
|
||||
output.WriteString(tag)
|
||||
output.WriteByte('>')
|
||||
}
|
||||
})
|
||||
|
||||
output.WriteString("</div>")
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func shouldRemoveCandidate(str string) bool {
|
||||
str = strings.ToLower(str)
|
||||
|
||||
@@ -190,17 +197,17 @@ func shouldRemoveCandidate(str string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
isFalsePositive := false
|
||||
for _, maybeCandidateToRemove := range maybeCandidateToRemove {
|
||||
if strings.Contains(str, maybeCandidateToRemove) {
|
||||
isFalsePositive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, unlikelyCandidateToRemove := range unlikelyCandidateToRemove {
|
||||
if strings.Contains(str, unlikelyCandidateToRemove) {
|
||||
// Do we have a false positive?
|
||||
for _, maybeCandidateToRemove := range maybeCandidateToRemove {
|
||||
if strings.Contains(str, maybeCandidateToRemove) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Nope, it's a true positive!
|
||||
return true
|
||||
return !isFalsePositive
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -294,12 +294,13 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
// The tag doesn't have every required attributes but we're still interested in its content
|
||||
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
|
||||
}
|
||||
buf.WriteString("<")
|
||||
buf.WriteByte('<')
|
||||
buf.WriteString(n.Data)
|
||||
if len(htmlAttributes) > 0 {
|
||||
buf.WriteString(" " + htmlAttributes)
|
||||
if htmlAttributes != "" {
|
||||
buf.WriteByte(' ')
|
||||
buf.WriteString(htmlAttributes)
|
||||
}
|
||||
buf.WriteString(">")
|
||||
buf.WriteByte('>')
|
||||
|
||||
if isSelfContainedTag(tag) {
|
||||
return nil
|
||||
@@ -312,7 +313,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
|
||||
buf.WriteString("</")
|
||||
buf.WriteString(n.Data)
|
||||
buf.WriteString(">")
|
||||
buf.WriteByte('>')
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
@@ -327,49 +328,16 @@ func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseU
|
||||
return nil
|
||||
}
|
||||
|
||||
func getExtraAttributes(tagName string, isYouTubeEmbed bool, sanitizerOptions *SanitizerOptions) []string {
|
||||
func hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {
|
||||
switch tagName {
|
||||
case "a":
|
||||
htmlAttributes := []string{`rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`}
|
||||
if sanitizerOptions.OpenLinksInNewTab {
|
||||
htmlAttributes = append(htmlAttributes, `target="_blank"`)
|
||||
}
|
||||
return htmlAttributes
|
||||
case "video", "audio":
|
||||
return []string{"controls"}
|
||||
return s.href
|
||||
case "iframe":
|
||||
extraHTMLAttributes := []string{`sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`}
|
||||
|
||||
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
|
||||
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
|
||||
if isYouTubeEmbed {
|
||||
extraHTMLAttributes = append(extraHTMLAttributes, `referrerpolicy="strict-origin-when-cross-origin"`)
|
||||
}
|
||||
|
||||
return extraHTMLAttributes
|
||||
case "img":
|
||||
return []string{`loading="lazy"`}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return slices.Contains(attributes, "href")
|
||||
case "iframe":
|
||||
return slices.Contains(attributes, "src")
|
||||
return s.src
|
||||
case "source", "img":
|
||||
for _, attribute := range attributes {
|
||||
if attribute == "src" || attribute == "srcset" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
return s.src || s.srcset
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasValidURIScheme(absoluteURL string) bool {
|
||||
@@ -508,9 +476,28 @@ func rewriteIframeURL(link string) string {
|
||||
return link
|
||||
}
|
||||
|
||||
type mandatoryAttributesStruct struct {
|
||||
href bool
|
||||
src bool
|
||||
srcset bool
|
||||
}
|
||||
|
||||
func trackAttributes(s *mandatoryAttributesStruct, attributeName string) {
|
||||
switch attributeName {
|
||||
case "href":
|
||||
s.href = true
|
||||
case "src":
|
||||
s.src = true
|
||||
case "srcset":
|
||||
s.srcset = true
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) (string, bool) {
|
||||
htmlAttrs := make([]string, 0, len(attributes))
|
||||
attrNames := make([]string, 0, len(attributes))
|
||||
|
||||
// Keep track of mandatory attributes for some tags
|
||||
mandatoryAttributes := mandatoryAttributesStruct{false, false, false}
|
||||
|
||||
var isAnchorLink bool
|
||||
var isYouTubeEmbed bool
|
||||
@@ -566,7 +553,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
case tagName == "iframe":
|
||||
iframeSourceDomain, trustedIframeDomain := findAllowedIframeSourceDomain(attribute.Val)
|
||||
if !trustedIframeDomain {
|
||||
continue
|
||||
return "", false
|
||||
}
|
||||
|
||||
value = rewriteIframeURL(attribute.Val)
|
||||
@@ -581,7 +568,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
isAnchorLink = true
|
||||
default:
|
||||
if isBlockedResource(value) {
|
||||
continue
|
||||
return "", false
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -602,16 +589,35 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
}
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
trackAttributes(&mandatoryAttributes, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
|
||||
}
|
||||
|
||||
if !hasRequiredAttributes(tagName, attrNames) {
|
||||
if !hasRequiredAttributes(&mandatoryAttributes, tagName) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !isAnchorLink {
|
||||
htmlAttrs = append(htmlAttrs, getExtraAttributes(tagName, isYouTubeEmbed, sanitizerOptions)...)
|
||||
switch tagName {
|
||||
case "a":
|
||||
htmlAttrs = append(htmlAttrs, `rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`)
|
||||
if sanitizerOptions.OpenLinksInNewTab {
|
||||
htmlAttrs = append(htmlAttrs, `target="_blank"`)
|
||||
}
|
||||
case "video", "audio":
|
||||
htmlAttrs = append(htmlAttrs, "controls")
|
||||
case "iframe":
|
||||
htmlAttrs = append(htmlAttrs, `sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`)
|
||||
|
||||
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
|
||||
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
|
||||
if isYouTubeEmbed {
|
||||
htmlAttrs = append(htmlAttrs, `referrerpolicy="strict-origin-when-cross-origin"`)
|
||||
}
|
||||
|
||||
case "img":
|
||||
htmlAttrs = append(htmlAttrs, `loading="lazy"`)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(htmlAttrs, " "), true
|
||||
|
||||
@@ -957,7 +957,6 @@ func TestAttrLowerCase(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)
|
||||
if tc.expected != output {
|
||||
|
||||
@@ -68,9 +68,16 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
|
||||
}
|
||||
|
||||
// It's not a feed, so we have to process its HTML.
|
||||
doc, err := parseHTMLDocument(responseHandler.ContentType(), responseBody)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
baseURL := getBaseURL(websiteURL, doc)
|
||||
|
||||
// Step 2) Find the canonical URL of the website.
|
||||
slog.Debug("Try to find the canonical URL of the website", slog.String("website_url", websiteURL))
|
||||
websiteURL = f.findCanonicalURL(websiteURL, responseHandler.ContentType(), responseBody)
|
||||
websiteURL = f.findCanonicalURL(websiteURL, baseURL, doc)
|
||||
|
||||
// Step 3) Check if the website URL is a YouTube channel.
|
||||
slog.Debug("Try to detect feeds for a YouTube page", slog.String("website_url", websiteURL))
|
||||
@@ -86,7 +93,8 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("content_type", responseHandler.ContentType()),
|
||||
)
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromWebPage(websiteURL, responseHandler.ContentType(), responseBody); localizedError != nil {
|
||||
|
||||
if subscriptions, localizedError := f.findSubscriptionsFromWebPage(baseURL, doc); localizedError != nil {
|
||||
return nil, localizedError
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from web page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
@@ -116,7 +124,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentType string, body []byte) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL string, doc *goquery.Document) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
queries := map[string]string{
|
||||
"link[type='application/rss+xml']": parser.FormatRSS,
|
||||
"link[type='application/atom+xml']": parser.FormatAtom,
|
||||
@@ -127,23 +135,6 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
"link[type='application/json']:not([href*='/wp-json/'])": parser.FormatJSON,
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
|
||||
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
websiteURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
var subscriptions Subscriptions
|
||||
subscriptionURLs := make(map[string]bool)
|
||||
for feedQuerySelector, feedFormat := range queries {
|
||||
@@ -152,6 +143,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
subscription.Type = feedFormat
|
||||
|
||||
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
|
||||
var err error
|
||||
subscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -326,26 +318,8 @@ func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Su
|
||||
|
||||
// findCanonicalURL extracts the canonical URL from the HTML <link rel="canonical"> tag.
|
||||
// Returns the canonical URL if found, otherwise returns the effective URL.
|
||||
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, contentType string, body []byte) string {
|
||||
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
|
||||
if err != nil {
|
||||
return effectiveURL
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
|
||||
if err != nil {
|
||||
return effectiveURL
|
||||
}
|
||||
|
||||
baseURL := effectiveURL
|
||||
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
canonicalHref, exists := doc.Find("link[rel='canonical' i]").First().Attr("href")
|
||||
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, baseURL string, doc *goquery.Document) string {
|
||||
canonicalHref, exists := doc.Find("head link[rel='canonical' i]").First().Attr("href")
|
||||
if !exists || strings.TrimSpace(canonicalHref) == "" {
|
||||
return effectiveURL
|
||||
}
|
||||
@@ -357,3 +331,28 @@ func (f *subscriptionFinder) findCanonicalURL(effectiveURL, contentType string,
|
||||
|
||||
return canonicalURL
|
||||
}
|
||||
|
||||
// getBaseURL returns the url specified in the <base> tag, and `websiteURL` otherwise.
|
||||
func getBaseURL(websiteURL string, doc *goquery.Document) string {
|
||||
baseURL := websiteURL
|
||||
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func parseHTMLDocument(contentType string, body []byte) (*goquery.Document, error) {
|
||||
htmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(htmlDocumentReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -118,8 +118,12 @@ func TestParseWebPageWithRssFeed(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -151,8 +155,12 @@ func TestParseWebPageWithAtomFeed(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -184,8 +192,12 @@ func TestParseWebPageWithJSONFeed(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -217,8 +229,12 @@ func TestParseWebPageWithOldJSONFeedMimeType(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -251,8 +267,12 @@ func TestParseWebPageWithJSONFeedWpJsonIgnored(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -272,8 +292,12 @@ func TestParseWebPageWithRelativeFeedURL(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -305,8 +329,12 @@ func TestParseWebPageWithEmptyTitle(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -339,8 +367,12 @@ func TestParseWebPageWithMultipleFeeds(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -361,8 +393,12 @@ func TestParseWebPageWithDuplicatedFeeds(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -394,8 +430,12 @@ func TestParseWebPageWithEmptyFeedURL(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -415,8 +455,12 @@ func TestParseWebPageWithNoHref(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", "text/html", []byte(htmlPage))
|
||||
subscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage("http://example.org/", doc)
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)
|
||||
}
|
||||
@@ -436,8 +480,12 @@ func TestFindCanonicalURL(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "text/html", []byte(htmlPage))
|
||||
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "http://example.org", doc)
|
||||
if canonicalURL != "https://example.org/canonical-page" {
|
||||
t.Errorf(`Unexpected canonical URL, got %q, expected %q`, canonicalURL, "https://example.org/canonical-page")
|
||||
}
|
||||
@@ -452,8 +500,12 @@ func TestFindCanonicalURLNotFound(t *testing.T) {
|
||||
<body>
|
||||
</body>
|
||||
</html>`
|
||||
doc, shouldNeverHappenErr := parseHTMLDocument("text/html", []byte(htmlPage))
|
||||
if shouldNeverHappenErr != nil {
|
||||
t.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)
|
||||
}
|
||||
|
||||
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "text/html", []byte(htmlPage))
|
||||
canonicalURL := NewSubscriptionFinder(nil).findCanonicalURL("https://example.org/page", "https://example.org", doc)
|
||||
if canonicalURL != "https://example.org/page" {
|
||||
t.Errorf(`Expected effective URL when canonical not found, got %q`, canonicalURL)
|
||||
}
|
||||
|
||||
@@ -131,20 +131,29 @@ func RemoveTrackingParameters(parsedFeedURL, parsedSiteURL, parsedInputUrl *url.
|
||||
return "", errors.New("urlcleaner: one of the URLs is nil")
|
||||
}
|
||||
|
||||
if parsedInputUrl.RawQuery == "" {
|
||||
return parsedInputUrl.String(), nil
|
||||
}
|
||||
|
||||
queryParams := parsedInputUrl.Query()
|
||||
hasTrackers := false
|
||||
feedHostname := parsedFeedURL.Hostname()
|
||||
siteHostname := parsedSiteURL.Hostname()
|
||||
|
||||
// Remove tracking parameters
|
||||
for param := range queryParams {
|
||||
lowerParam := strings.ToLower(param)
|
||||
|
||||
if isTrackingParam(lowerParam) {
|
||||
queryParams.Del(param)
|
||||
hasTrackers = true
|
||||
continue
|
||||
}
|
||||
|
||||
if trackingParamsOutbound[lowerParam] {
|
||||
// handle duplicate parameters like ?a=b&a=c&a=d…
|
||||
for _, value := range queryParams[param] {
|
||||
if value == parsedFeedURL.Hostname() || value == parsedSiteURL.Hostname() {
|
||||
if value == feedHostname || value == siteHostname {
|
||||
queryParams.Del(param)
|
||||
hasTrackers = true
|
||||
break
|
||||
|
||||
@@ -78,6 +78,62 @@ func TestXMLDocumentWithISO88591FileEncodingButUTF8Prolog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLDocumentWithKOI8REncoding(t *testing.T) {
|
||||
fp, err := os.Open("testdata/koi8r.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
type item struct {
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
}
|
||||
|
||||
type channel struct {
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
Items []item `xml:"item"`
|
||||
}
|
||||
|
||||
type rss struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel channel `xml:"channel"`
|
||||
}
|
||||
|
||||
var doc rss
|
||||
|
||||
decoder := NewXMLDecoder(fp)
|
||||
err = decoder.Decode(&doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if doc.Channel.Title != "Пример RSS ленты" {
|
||||
t.Errorf("Incorrect channel title, expected: %q, got: %q", "Пример RSS ленты", doc.Channel.Title)
|
||||
}
|
||||
|
||||
if doc.Channel.Description != "Тестовая лента в кодировке KOI8-R" {
|
||||
t.Errorf("Incorrect channel description, expected: %q, got: %q", "Тестовая лента в кодировке KOI8-R", doc.Channel.Description)
|
||||
}
|
||||
|
||||
if len(doc.Channel.Items) != 2 {
|
||||
t.Fatalf("Incorrect number of items, expected: %d, got: %d", 2, len(doc.Channel.Items))
|
||||
}
|
||||
|
||||
if doc.Channel.Items[0].Title != "Первая новость" {
|
||||
t.Errorf("Incorrect first item title, expected: %q, got: %q", "Первая новость", doc.Channel.Items[0].Title)
|
||||
}
|
||||
|
||||
if !strings.Contains(doc.Channel.Items[0].Description, "Привет мир! Ёжик, чай, Москва, Санкт-Петербург.") {
|
||||
t.Errorf("First item description does not contain expected text, got: %q", doc.Channel.Items[0].Description)
|
||||
}
|
||||
|
||||
if !strings.Contains(doc.Channel.Items[1].Description, "Проверка специальных символов") {
|
||||
t.Errorf("Second item description does not contain expected text, got: %q", doc.Channel.Items[1].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLDocumentWithIllegalUnicodeCharacters(t *testing.T) {
|
||||
type myxml struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="KOI8-R"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>ðÒÉÍÅÒ RSS ÌÅÎÔÙ</title>
|
||||
<link>http://example.com/</link>
|
||||
<description>ôÅÓÔÏ×ÁÑ ÌÅÎÔÁ × ËÏÄÉÒÏ×ËÅ KOI8-R</description>
|
||||
<language>ru</language>
|
||||
<lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>
|
||||
|
||||
<item>
|
||||
<title>ðÅÒ×ÁÑ ÎÏ×ÏÓÔØ</title>
|
||||
<link>http://example.com/post1</link>
|
||||
<guid>http://example.com/post1</guid>
|
||||
<pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>
|
||||
<description>
|
||||
üÔÏ ÔÅÓÔÏ×ÏÅ ÏÐÉÓÁÎÉÅ Ó ÒÕÓÓËÉÍÉ ÓÉÍ×ÏÌÁÍÉ:
|
||||
ðÒÉ×ÅÔ ÍÉÒ! ³ÖÉË, ÞÁÊ, íÏÓË×Á, óÁÎËÔ-ðÅÔÅÒÂÕÒÇ.
|
||||
</description>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>÷ÔÏÒÁÑ ÎÏ×ÏÓÔØ</title>
|
||||
<link>http://example.com/post2</link>
|
||||
<guid>http://example.com/post2</guid>
|
||||
<pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>
|
||||
<description>
|
||||
ðÒÏ×ÅÒËÁ ÓÐÅÃÉÁÌØÎÙÈ ÓÉÍ×ÏÌÏ×: & < >
|
||||
á ÔÁËÖÅ ÞÉÓÌÁ: 1234567890.
|
||||
</description>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -263,10 +263,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
webhook_url,
|
||||
disable_http2,
|
||||
description,
|
||||
proxy_url
|
||||
proxy_url,
|
||||
ignore_entry_updates
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
@@ -302,6 +303,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
feed.DisableHTTP2,
|
||||
feed.Description,
|
||||
feed.ProxyURL,
|
||||
feed.IgnoreEntryUpdates,
|
||||
).Scan(&feed.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
|
||||
@@ -384,9 +386,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
ntfy_topic=$35,
|
||||
pushover_enabled=$36,
|
||||
pushover_priority=$37,
|
||||
proxy_url=$38
|
||||
proxy_url=$38,
|
||||
ignore_entry_updates=$39
|
||||
WHERE
|
||||
id=$39 AND user_id=$40
|
||||
id=$40 AND user_id=$41
|
||||
`
|
||||
_, err = s.db.Exec(query,
|
||||
feed.FeedURL,
|
||||
@@ -427,6 +430,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
feed.PushoverEnabled,
|
||||
feed.PushoverPriority,
|
||||
feed.ProxyURL,
|
||||
feed.IgnoreEntryUpdates,
|
||||
feed.ID,
|
||||
feed.UserID,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user