Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f2e0d819 | |||
| 510d225b06 | |||
| ecdc3569e3 | |||
| f96bee0d61 | |||
| 15505142fd | |||
| 6ddddbc4c2 | |||
| 063e3f14f0 | |||
| ae1f1351bf | |||
| 5de36b613c | |||
| 2ac3073583 | |||
| ae6ac73ce4 | |||
| 9f4b2ef9fc | |||
| bcb2cf2aa2 | |||
| 530b0c5739 | |||
| b0d25c1c68 | |||
| 8699a1d119 | |||
| a1539659f3 | |||
| 1975bc6339 | |||
| f72957e807 | |||
| ce5d84e956 | |||
| 49d8f1dafb | |||
| 91860edab0 | |||
| e49c75fc56 | |||
| a14a180313 | |||
| 684ea3d224 | |||
| 4ce8151a8f | |||
| 7d8ffd2eb0 | |||
| 05cd8da81f | |||
| f22c08832a | |||
| f4de355374 | |||
| f963ea35a5 | |||
| 87d7891600 | |||
| 939698fd66 | |||
| 350df63d99 | |||
| 0cfd0798b9 | |||
| f4b393748d | |||
| 102989656b | |||
| 533ff80744 | |||
| 83ea3d1912 | |||
| 651fbd1560 | |||
| 5e766332be | |||
| 2717336d2c |
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
if: matrix.language == 'go'
|
||||
@@ -46,14 +46,14 @@ jobs:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -40,13 +40,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -66,13 +66,13 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -52,21 +52,21 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -74,14 +74,14 @@ jobs:
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 14
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.5
|
||||
image: postgres:11
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
|
||||
func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntryIDsContext(ctx, filter)
|
||||
}
|
||||
|
||||
// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
|
||||
func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
|
||||
body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var result EntryIDsResultSet
|
||||
if err := json.NewDecoder(body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
|
||||
if filter == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if filter.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(filter.Limit))
|
||||
}
|
||||
if filter.Offset > 0 {
|
||||
params.Set("offset", strconv.Itoa(filter.Offset))
|
||||
}
|
||||
if filter.Starred != nil {
|
||||
params.Set("starred", strconv.FormatBool(*filter.Starred))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
params.Set("status", filter.Status)
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
return path
|
||||
}
|
||||
|
||||
return path + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Entries fetches entries using the given filter.
|
||||
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
@@ -981,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntriesStarred updates the starred state of a list of entries.
|
||||
func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
|
||||
}
|
||||
|
||||
// UpdateEntriesStarredContext updates the starred state of a list of entries.
|
||||
func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Starred *bool `json:"starred"`
|
||||
}
|
||||
|
||||
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntry updates an entry.
|
||||
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
@@ -1231,6 +1298,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
|
||||
values.Add("status", status)
|
||||
}
|
||||
|
||||
for _, tag := range filter.Tags {
|
||||
values.Add("tags", tag)
|
||||
}
|
||||
|
||||
path = fmt.Sprintf("%s?%s", path, values.Encode())
|
||||
}
|
||||
|
||||
|
||||
@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntriesStarred(t *testing.T) {
|
||||
starred := true
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
|
||||
expectFromJSON(t, req.Body, &struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Starred *bool `json:"starred"`
|
||||
}{
|
||||
EntryIDs: []int64{1, 2},
|
||||
Starred: &starred,
|
||||
})
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
|
||||
})))
|
||||
if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry(t *testing.T) {
|
||||
expected := &Entry{
|
||||
ID: 1,
|
||||
@@ -1286,3 +1307,110 @@ func TestUpdateEnclosure(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestEntryIDsNoFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 2,
|
||||
EntryIDs: []int64{1, 2},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithPaginationFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 5,
|
||||
EntryIDs: []int64{3},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithStarredFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 1,
|
||||
EntryIDs: []int64{42},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithStatusFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 10,
|
||||
EntryIDs: []int64{7, 8},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithCombinedFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 3,
|
||||
EntryIDs: []int64{5},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +146,15 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
NextCheckAt time.Time `json:"next_check_at"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
NoMediaPlayer bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
@@ -172,6 +175,14 @@ type Feed struct {
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
NtfyTopic string `json:"ntfy_topic"`
|
||||
PushoverEnabled bool `json:"pushover_enabled"`
|
||||
PushoverPriority int `json:"pushover_priority"`
|
||||
Icon *FeedIcon `json:"icon"`
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
@@ -185,6 +196,7 @@ type FeedCreationRequest struct {
|
||||
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"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
@@ -205,6 +217,7 @@ type FeedModificationRequest struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
UrlRewriteRules *string `json:"urlrewrite_rules"`
|
||||
@@ -220,6 +233,7 @@ type FeedModificationRequest struct {
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
NoMediaPlayer *bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
@@ -318,6 +332,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
Tags []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
@@ -327,6 +342,20 @@ type EntryResultSet struct {
|
||||
Entries Entries `json:"entries"`
|
||||
}
|
||||
|
||||
// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
|
||||
type EntryIDsFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Starred *bool
|
||||
Status string
|
||||
}
|
||||
|
||||
// EntryIDsResultSet represents the response when fetching entry ID lists.
|
||||
type EntryIDsResultSet struct {
|
||||
Total int `json:"total"`
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
}
|
||||
|
||||
// VersionResponse represents the version and the build information of the Miniflux instance.
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
|
||||
@@ -8,17 +8,17 @@ go 1.26.0
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.13
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -45,6 +45,6 @@ require (
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -88,10 +88,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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
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=
|
||||
@@ -106,8 +106,8 @@ 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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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=
|
||||
@@ -128,8 +128,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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.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=
|
||||
@@ -139,8 +139,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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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=
|
||||
@@ -150,8 +150,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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
+2
-1
@@ -54,8 +54,9 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
|
||||
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
|
||||
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
|
||||
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
|
||||
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusAndStarredHandler)
|
||||
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
|
||||
|
||||
@@ -2631,6 +2631,303 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
entryID := result.Entries[0].ID
|
||||
|
||||
// Star the entry without changing its status.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`Expected entry to be starred`)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusUnread {
|
||||
t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
|
||||
}
|
||||
|
||||
// Unstar the entry.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Starred {
|
||||
t.Fatalf(`Expected entry to no longer be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
// A new user should have no entries at all.
|
||||
result, err := regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.EntryIDs == nil {
|
||||
t.Fatal(`Entry IDs should not be nil`)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != 0 {
|
||||
t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
|
||||
}
|
||||
|
||||
// Subscribe to a feed so there are entries.
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allEntries, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(allEntries.Entries) == 0 {
|
||||
t.Fatal(`Expected feed to have entries`)
|
||||
}
|
||||
|
||||
// Without filters, all entries should be returned.
|
||||
result, err = regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != allEntries.Total {
|
||||
t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
|
||||
}
|
||||
|
||||
// Filter by status=unread: all entries should be unread initially.
|
||||
unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Mark one entry as read and verify status filter results update.
|
||||
firstEntryID := allEntries.Entries[0].ID
|
||||
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
if unreadResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
|
||||
}
|
||||
|
||||
for _, id := range unreadResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
|
||||
}
|
||||
|
||||
// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
|
||||
if allEntries.Total >= 2 {
|
||||
pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
|
||||
}
|
||||
|
||||
// offset=1 should skip the first entry.
|
||||
offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(offsetResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
|
||||
}
|
||||
|
||||
if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
|
||||
t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by starred=true: initially no starred entries.
|
||||
starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Star the first entry and verify it appears in starred results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 1 {
|
||||
t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
if starredResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
|
||||
}
|
||||
|
||||
// The read starred entry should appear when filtering by starred=true (read status does not affect it).
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// starred=false should exclude the starred entry.
|
||||
notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range notStarredResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination with offset past the single starred result should return 0 entries but total 1.
|
||||
pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedStarred.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedStarred.Total != 1 {
|
||||
t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
|
||||
}
|
||||
|
||||
// Unstarring the entry should remove it from starred=true results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 0 {
|
||||
t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
// Invalid starred value should return 400.
|
||||
_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
|
||||
if err == nil {
|
||||
t.Fatal(`Expected error for invalid status parameter, got nil`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
// Unauthenticated request should be rejected before param validation.
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
// Unauthenticated request should be rejected before param validation.
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsDefaults(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected default limit 10000, got %d`, limit)
|
||||
}
|
||||
|
||||
if offset != 0 {
|
||||
t.Fatalf(`Expected default offset 0, got %d`, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsCustomValues(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 500 {
|
||||
t.Fatalf(`Expected limit 500, got %d`, limit)
|
||||
}
|
||||
|
||||
if offset != 100 {
|
||||
t.Fatalf(`Expected offset 100, got %d`, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
|
||||
limit, _ := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
|
||||
limit, _ := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
@@ -112,14 +112,19 @@ func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Reque
|
||||
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var categories model.Categories
|
||||
var err error
|
||||
includeCounts := request.QueryStringParam(r, "counts", "false")
|
||||
|
||||
if includeCounts == "true" {
|
||||
if request.QueryBoolParam(r, "counts", false) {
|
||||
user, userErr := h.store.UserByID(request.UserID(r))
|
||||
if userErr != nil {
|
||||
response.JSONServerError(w, r, userErr)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
|
||||
} else {
|
||||
categories, err = h.store.Categories(request.UserID(r))
|
||||
|
||||
@@ -22,7 +22,7 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -33,12 +33,6 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
response.JSON(w, r, enclosure)
|
||||
@@ -62,7 +56,7 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -73,12 +67,6 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
|
||||
@@ -175,11 +175,11 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
|
||||
if globallyVisible {
|
||||
builder.WithGloballyVisible()
|
||||
builder = builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
configureFilters(builder, r)
|
||||
builder = configureFilters(builder, r)
|
||||
|
||||
entries, count, err := builder.GetEntriesWithCount()
|
||||
if err != nil {
|
||||
@@ -189,26 +189,36 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
|
||||
entries[i].Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
}
|
||||
|
||||
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
if entriesStatusUpdateRequest.Status != "" {
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if entriesStatusUpdateRequest.Starred != nil {
|
||||
if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
@@ -497,57 +507,108 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
starredValue := request.QueryStringParam(r, "starred", "")
|
||||
if starredValue != "true" && starredValue != "false" {
|
||||
response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "status") {
|
||||
statusValue := request.QueryStringParam(r, "status", "")
|
||||
if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
|
||||
response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithSorting("id", "DESC").
|
||||
WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
|
||||
WithOffset(offset)
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
builder.WithStarred(request.QueryBoolParam(r, "starred", false))
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "status") {
|
||||
builder.WithStatuses(request.QueryStringParam(r, "status", ""))
|
||||
}
|
||||
|
||||
entryIDs, total, err := builder.GetEntryIDsWithCount()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entryIDs == nil {
|
||||
entryIDs = []int64{}
|
||||
}
|
||||
|
||||
response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
go h.store.FlushHistory(loggedUserID)
|
||||
response.JSONAccepted(w, r)
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) *storage.EntryQueryBuilder {
|
||||
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
|
||||
builder.BeforeEntryID(beforeEntryID)
|
||||
builder = builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
|
||||
builder.AfterEntryID(afterEntryID)
|
||||
builder = builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
|
||||
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
|
||||
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
|
||||
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder = builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
|
||||
if err == nil {
|
||||
builder.WithStarred(starred)
|
||||
builder = builder.WithStarred(starred)
|
||||
}
|
||||
}
|
||||
|
||||
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
|
||||
builder.WithSearchQuery(searchQuery)
|
||||
builder = builder.WithSearchQuery(searchQuery)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
func parseEntryIDsParams(r *http.Request) (limit, offset int) {
|
||||
limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
|
||||
if limit <= 0 || limit > model.MaxEntryIDsLimit {
|
||||
limit = model.MaxEntryIDsLimit
|
||||
}
|
||||
offset = request.QueryIntParam(r, "offset", 0)
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
originalFeed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
response.JSONNotFound(w, r)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ type entryIDResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type entryIDsResponse struct {
|
||||
Total int `json:"total"`
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
}
|
||||
|
||||
type entryContentResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
|
||||
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Re
|
||||
rssbridgeToken = intg.RSSBridgeToken
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
|
||||
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
|
||||
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
|
||||
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy).
|
||||
WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(subscriptionDiscoveryRequest.Cookie).
|
||||
WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password).
|
||||
IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
|
||||
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
|
||||
subscriptionDiscoveryRequest.URL,
|
||||
|
||||
@@ -22,6 +22,11 @@ func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
@@ -113,7 +118,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -128,7 +139,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -181,7 +198,7 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -203,7 +220,7 @@ func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request)
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -25,24 +25,24 @@ func askCredentials() (string, string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
|
||||
printfAndExit("unable to read username: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
|
||||
state, err := term.GetState(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
|
||||
printfAndExit("unable to get terminal state: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if restoreErr := term.Restore(fd, state); restoreErr != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
|
||||
printfAndExit("unable to restore terminal state: %w", restoreErr)
|
||||
}
|
||||
}()
|
||||
|
||||
bytePassword, err := term.ReadPassword(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
|
||||
printfAndExit("unable to read password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("\n")
|
||||
|
||||
+11
-6
@@ -124,7 +124,7 @@ func Parse() {
|
||||
default:
|
||||
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
|
||||
printfAndExit("unable to open log file: %v", err)
|
||||
}
|
||||
defer logFileHandler.(*os.File).Close()
|
||||
}
|
||||
@@ -143,15 +143,15 @@ func Parse() {
|
||||
}
|
||||
|
||||
if err := static.GenerateBinaryBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
|
||||
printfAndExit("unable to generate binary files bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateStylesheetsBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
|
||||
printfAndExit("unable to generate stylesheets bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
|
||||
printfAndExit("unable to generate javascript bundle: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(
|
||||
@@ -161,7 +161,7 @@ func Parse() {
|
||||
config.Opts.DatabaseConnectionLifetime(),
|
||||
)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
|
||||
printfAndExit("unable to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
@@ -231,7 +231,7 @@ func Parse() {
|
||||
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
|
||||
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
|
||||
printfAndExit("unable to initialize proxy rotator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,3 +252,8 @@ func printErrorAndExit(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func printfAndExit(format string, args ...any) {
|
||||
err := fmt.Errorf(format, args...)
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
@@ -13,17 +13,17 @@ import (
|
||||
func exportUserFeeds(store *storage.Storage, username string) {
|
||||
user, err := store.UserByUsername(username)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
|
||||
printfAndExit("unable to find user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
printErrorAndExit(fmt.Errorf("user %q not found", username))
|
||||
printfAndExit("user %q not found", username)
|
||||
}
|
||||
|
||||
opmlHandler := opml.NewHandler(store)
|
||||
opmlExport, err := opmlHandler.Export(user.ID)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
|
||||
printfAndExit("unable to export feeds: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(opmlExport)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -22,12 +21,12 @@ func doHealthCheck(healthCheckEndpoint string) {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(healthCheckEndpoint)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
|
||||
printfAndExit(`health check failure: %v`, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
|
||||
printfAndExit(`health check failed with status code %d`, resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Debug(`Health check is passing`)
|
||||
|
||||
@@ -353,7 +353,11 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
|
||||
// This migration originally used md5(url), but it was changed to
|
||||
// sha256 because PostgreSQL 18 disables MD5 in FIPS mode, which made
|
||||
// fresh installs fail while replaying this migration. Existing
|
||||
// installs that already ran it are migrated later on.
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
@@ -724,7 +728,12 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
}
|
||||
|
||||
// Create unique index
|
||||
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
|
||||
//
|
||||
// This originally used md5(url), but it was changed to sha256 because
|
||||
// PostgreSQL 18 disables MD5 in FIPS mode, which made fresh installs
|
||||
// fail while replaying this migration. Existing installs that already
|
||||
// ran it are migrated later on.
|
||||
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1525,4 +1534,15 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// PostgreSQL 18 disables MD5 when running in FIPS mode, which makes
|
||||
// the unique index on enclosures relying on md5(url) unusable.
|
||||
// Replace it with a SHA-256 based expression index.
|
||||
_, err = tx.Exec(`
|
||||
DROP INDEX IF EXISTS enclosures_user_entry_url_unique_idx;
|
||||
CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx
|
||||
ON enclosures (user_id, entry_id, encode(sha256(url::bytea), 'hex'));
|
||||
`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -249,8 +249,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("since_id", sinceID),
|
||||
)
|
||||
builder.AfterEntryID(sinceID)
|
||||
builder.WithSorting("id", "ASC")
|
||||
builder = builder.AfterEntryID(sinceID)
|
||||
builder = builder.WithSorting("id", "ASC")
|
||||
}
|
||||
case request.HasQueryParam(r, "max_id"):
|
||||
maxID := request.QueryInt64Param(r, "max_id", 0)
|
||||
@@ -258,14 +258,14 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("[Fever] Fetching most recent items",
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
builder.WithSorting("id", "DESC")
|
||||
builder = builder.WithSorting("id", "DESC")
|
||||
} else if maxID > 0 {
|
||||
slog.Debug("[Fever] Fetching items before a given item ID",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("max_id", maxID),
|
||||
)
|
||||
builder.BeforeEntryID(maxID)
|
||||
builder.WithSorting("id", "DESC")
|
||||
builder = builder.BeforeEntryID(maxID)
|
||||
builder = builder.WithSorting("id", "DESC")
|
||||
}
|
||||
case request.HasQueryParam(r, "with_ids"):
|
||||
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
|
||||
@@ -278,7 +278,7 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
|
||||
builder.WithEntryIDs(itemIDs...)
|
||||
builder = builder.WithEntryIDs(itemIDs...)
|
||||
}
|
||||
default:
|
||||
slog.Debug("[Fever] Fetching oldest items",
|
||||
|
||||
@@ -342,9 +342,10 @@ func (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithUserAgent("", config.Opts.HTTPClientUserAgent())
|
||||
|
||||
var rssBridgeURL string
|
||||
var rssBridgeToken string
|
||||
@@ -1018,7 +1019,7 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
builder.WithStatuses(model.EntryStatusUnread)
|
||||
builder = builder.WithStatuses(model.EntryStatusUnread)
|
||||
default:
|
||||
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
|
||||
slog.String("handler", "handleReadingListStreamHandler"),
|
||||
@@ -1030,11 +1031,11 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
@@ -1053,11 +1054,11 @@ func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *ht
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
@@ -1077,11 +1078,11 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
@@ -1129,16 +1130,16 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
if s.Type == ReadStream {
|
||||
builder.WithoutStatus(model.EntryStatusRead)
|
||||
builder = builder.WithoutStatus(model.EntryStatusRead)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,10 +120,10 @@ type contentItemOrigin struct {
|
||||
}
|
||||
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithBodyAsString("Unauthorized")
|
||||
builder.Write()
|
||||
response.NewBuilder(w, r).
|
||||
WithStatus(http.StatusUnauthorized).
|
||||
WithHeader("X-Reader-Google-Bad-Token", "true").
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithBodyAsString("Unauthorized").
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -86,7 +87,9 @@ func (b *Builder) WithoutCompression() *Builder {
|
||||
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
||||
etag = normalizeETag(etag)
|
||||
b.headers.Set("ETag", etag)
|
||||
b.headers.Set("Cache-Control", "public, immutable")
|
||||
// max-age is required for the "immutable" directive to take effect: without
|
||||
// it, browsers still revalidate content-hashed assets on every reload.
|
||||
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
|
||||
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
|
||||
|
||||
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
|
||||
|
||||
@@ -240,7 +240,7 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedHeader := "public, immutable"
|
||||
expectedHeader := "public, max-age=60, immutable"
|
||||
actualHeader := resp.Header.Get("Cache-Control")
|
||||
if actualHeader != expectedHeader {
|
||||
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
|
||||
@@ -297,7 +297,7 @@ func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Cache-Control") != "public, immutable" {
|
||||
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
|
||||
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,17 @@ import (
|
||||
|
||||
// HTML creates a new HTML response with a 200 status code.
|
||||
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder := NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
|
||||
switch v := any(body).(type) {
|
||||
case []byte:
|
||||
builder.WithBodyAsBytes(v)
|
||||
builder = builder.WithBodyAsBytes(v)
|
||||
case string:
|
||||
builder.WithBodyAsString(v)
|
||||
builder = builder.WithBodyAsString(v)
|
||||
}
|
||||
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -42,13 +44,13 @@ func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusInternalServerError).
|
||||
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString(html.EscapeString(err.Error())).
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLBadRequest sends a bad request error to the client.
|
||||
@@ -66,13 +68,13 @@ func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusBadRequest).
|
||||
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString(html.EscapeString(err.Error())).
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLForbidden sends a forbidden error to the client.
|
||||
@@ -89,12 +91,12 @@ func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString("Access Forbidden")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusForbidden).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString("Access Forbidden").
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLNotFound sends a page not found error to the client.
|
||||
@@ -111,12 +113,12 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString("Page Not Found")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNotFound).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString("Page Not Found").
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLRedirect redirects the user to a relative path or an absolute http(s) URL.
|
||||
@@ -142,11 +144,11 @@ func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, co
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithHeader("Content-Range", contentRange)
|
||||
builder.WithBodyAsString("Range Not Satisfiable")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusRequestedRangeNotSatisfiable).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithHeader("Content-Range", contentRange).
|
||||
WithBodyAsString("Range Not Satisfiable").
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ func JSON(w http.ResponseWriter, r *http.Request, body any) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(responseBody)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(responseBody).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONCreated sends a created response to the client.
|
||||
@@ -36,19 +36,19 @@ func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(responseBody)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusCreated).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(responseBody).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONAccepted sends an accepted response to the client.
|
||||
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusAccepted)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusAccepted).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONServerError sends an internal error to the client.
|
||||
@@ -66,11 +66,11 @@ func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(err))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusInternalServerError).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONBadRequest sends a bad request error to the client.
|
||||
@@ -88,11 +88,11 @@ func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(err))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusBadRequest).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONUnauthorized sends a not authorized error to the client.
|
||||
@@ -109,11 +109,11 @@ func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusUnauthorized).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("access unauthorized"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONForbidden sends a forbidden error to the client.
|
||||
@@ -130,11 +130,11 @@ func JSONForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusForbidden).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("access forbidden"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONNotFound sends a page not found error to the client.
|
||||
@@ -151,11 +151,11 @@ func JSONNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNotFound).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
func generateJSONError(err error) []byte {
|
||||
|
||||
@@ -17,7 +17,7 @@ const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-actio
|
||||
|
||||
// NoContent sends a no content response to the client.
|
||||
func NoContent(w http.ResponseWriter, r *http.Request) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNoContent)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNoContent).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import "net/http"
|
||||
|
||||
// Text writes a standard text response with a status 200 OK.
|
||||
func Text(w http.ResponseWriter, r *http.Request, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", `text/plain; charset=utf-8`).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -7,17 +7,17 @@ import "net/http"
|
||||
|
||||
// XML writes a standard XML response with a status 200 OK.
|
||||
func XML(w http.ResponseWriter, r *http.Request, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/xml; charset=utf-8").
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
// XMLAttachment forces the XML document to be downloaded by the web browser.
|
||||
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithAttachment(filename)
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/xml; charset=utf-8").
|
||||
WithAttachment(filename).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
|
||||
t.Errorf("Expected French translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language (should use key as fallback with args applied)
|
||||
// Test with missing language (should fall back to the untranslated key)
|
||||
result = wrapper.Translate("invalid_lang")
|
||||
expected = "error.test_key%!(EXTRA string=test message, int=404)"
|
||||
expected = "error.test_key"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
|
||||
localizedErr := NewLocalizedError("error.missing", "arg1")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "error.missing%!(EXTRA string=arg1)"
|
||||
expected := "error.missing"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
|
||||
|
||||
// Test with missing language
|
||||
result = localizedErr.Translate("invalid_lang")
|
||||
expected = "error.permission%!(EXTRA string=admin panel)"
|
||||
expected = "error.permission"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...any) string {
|
||||
return fmt.Sprintf(p.Print(key), args...)
|
||||
return formatTranslation(p.Print(key), args...)
|
||||
}
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
|
||||
if choices, found := dict.plurals[key]; found {
|
||||
index := getPluralForm(p.language, n)
|
||||
if len(choices) > index {
|
||||
return fmt.Sprintf(choices[index], args...)
|
||||
return formatTranslation(choices[index], args...)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// formatTranslation skips extra arguments when the translation references no argument,
|
||||
// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
|
||||
// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
|
||||
func formatTranslation(format string, args ...any) string {
|
||||
if !hasFormattingDirective(format) {
|
||||
return fmt.Sprintf(format, []any{}...)
|
||||
}
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// hasFormattingDirective reports whether the format should be handled with the
|
||||
// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
|
||||
// any other percent sequence, including a dangling "%".
|
||||
func hasFormattingDirective(format string) bool {
|
||||
for index := 0; index < len(format); index++ {
|
||||
if format[index] != '%' {
|
||||
continue
|
||||
}
|
||||
if index+1 >= len(format) {
|
||||
return true
|
||||
}
|
||||
if format[index+1] == '%' {
|
||||
index++ // skip the escaped percent
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralFormWithoutPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"ar_SA": translationDict{
|
||||
plurals: map[string][]string{
|
||||
// The Arabic dual omits the count by design.
|
||||
"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("ar_SA")
|
||||
|
||||
if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
|
||||
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
|
||||
}
|
||||
if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
|
||||
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
|
||||
}
|
||||
if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
|
||||
t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"media.completion": "Mark as read at 90%% completion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := NewPrinter("en_US").Printf("media.completion")
|
||||
expected := "Mark as read at 90% completion"
|
||||
if got != expected {
|
||||
t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFormattingDirective(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"دقيقتين": false,
|
||||
"%d دقيقة": true,
|
||||
"90%% done": false, // escaped percent consumes no argument
|
||||
"%d of %s": true,
|
||||
"": false,
|
||||
"%": true,
|
||||
}
|
||||
|
||||
for format, expected := range tests {
|
||||
if got := hasFormattingDirective(format); got != expected {
|
||||
t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,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.ignore_entry_updates": "Updates ignorieren",
|
||||
"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",
|
||||
@@ -186,7 +186,7 @@
|
||||
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
|
||||
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
|
||||
"form.feed.label.keep_filter_entry_rules": "Erlaubnisregeln",
|
||||
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
"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.apprise_service_urls": "Lista de URLs separadas por comas 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",
|
||||
@@ -216,7 +216,7 @@
|
||||
"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_services_url": "Lista de URLs separadas por comas 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",
|
||||
@@ -305,9 +305,9 @@
|
||||
"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_api_key": "Clave da API de Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas para 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",
|
||||
@@ -329,27 +329,27 @@
|
||||
"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_bot_token": "Token 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_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.wallabag_tags": "Etiquetas para 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.application_settings": "Axustes da aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
|
||||
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
|
||||
"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.help.external_font_hosts": "Lista de servidores de tipos de letra externos permitidos separados por espazos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Ler artigos abrindo 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)",
|
||||
@@ -367,11 +367,11 @@
|
||||
"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.mark_read_on_media_completion": "Só marcar como lido cando acada o 90%% da reprodución",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente como lidas as entradas ao velas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Para son/vídeo, marcar como lido ao chegar ao 90%% da reprodución",
|
||||
"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.open_external_links_in_new_tab": "Abrir ligazóns externas en nova pestana (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",
|
||||
@@ -428,8 +428,8 @@
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "Sen ler",
|
||||
"menu.users": "Usuarias",
|
||||
"page.about.authors_label": "Autorías:",
|
||||
"page.about.authors_value": "Frédéric Guillot e colaboradores",
|
||||
"page.about.authors_label": "Autoría:",
|
||||
"page.about.authors_value": "Frédéric Guillot e colaboradoras",
|
||||
"page.about.build_date": "Data da versión:",
|
||||
"page.about.credits": "Crédito",
|
||||
"page.about.db_usage": "Tamaño da BDD:",
|
||||
|
||||
@@ -19,6 +19,10 @@ const (
|
||||
// and for the user "entries_per_page" preference.
|
||||
const MaxEntryLimit = 1000
|
||||
|
||||
// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
|
||||
// for the entry ID list endpoints.
|
||||
const MaxEntryIDsLimit = 10000
|
||||
|
||||
// Entry represents a feed item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -76,6 +80,7 @@ type Entries []*Entry
|
||||
type EntriesStatusUpdateRequest struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
Starred *bool `json:"starred"`
|
||||
}
|
||||
|
||||
// EntryUpdateRequest represents a request to update an entry.
|
||||
|
||||
@@ -5,6 +5,7 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
@@ -19,7 +20,10 @@ type atom03Adapter struct {
|
||||
}
|
||||
|
||||
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed := new(model.Feed)
|
||||
feed := &model.Feed{
|
||||
FeedURL: baseURL,
|
||||
SiteURL: baseURL,
|
||||
}
|
||||
|
||||
// Populate the feed URL.
|
||||
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
|
||||
@@ -27,8 +31,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
|
||||
feed.FeedURL = absoluteFeedURL
|
||||
}
|
||||
} else {
|
||||
feed.FeedURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the site URL.
|
||||
@@ -37,8 +39,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
}
|
||||
} else {
|
||||
feed.SiteURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the feed title.
|
||||
@@ -69,6 +69,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if entry.Title == "" {
|
||||
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||
}
|
||||
|
||||
if entry.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
}
|
||||
@@ -81,17 +82,24 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
|
||||
// Populate the entry date.
|
||||
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
|
||||
if parsedDate, err := date.Parse(value); err == nil {
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
} else {
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedDate, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from Atom 0.3 feed",
|
||||
slog.String("date", value),
|
||||
slog.String("id", atomEntry.ID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
}
|
||||
|
||||
if entry.Date.IsZero() {
|
||||
entry.Date = time.Now()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,12 +20,11 @@ type atom10Adapter struct {
|
||||
atomFeed *atom10Feed
|
||||
}
|
||||
|
||||
func NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {
|
||||
return &atom10Adapter{atomFeed}
|
||||
}
|
||||
|
||||
func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
feed := new(model.Feed)
|
||||
func (a *atom10Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed := &model.Feed{
|
||||
FeedURL: baseURL,
|
||||
SiteURL: baseURL,
|
||||
}
|
||||
|
||||
// Populate the feed URL.
|
||||
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
|
||||
@@ -35,8 +32,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
|
||||
feed.FeedURL = absoluteFeedURL
|
||||
}
|
||||
} else {
|
||||
feed.FeedURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the site URL.
|
||||
@@ -45,8 +40,6 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
}
|
||||
} else {
|
||||
feed.SiteURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the feed title.
|
||||
@@ -59,15 +52,17 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
feed.Description = a.atomFeed.Subtitle.body()
|
||||
|
||||
// Populate the feed icon.
|
||||
if a.atomFeed.Icon != "" {
|
||||
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
|
||||
feed.IconURL = absoluteIconURL
|
||||
for _, value := range []string{a.atomFeed.Icon, a.atomFeed.Logo} {
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
} else if a.atomFeed.Logo != "" {
|
||||
if absoluteLogoURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
|
||||
feed.IconURL = absoluteLogoURL
|
||||
|
||||
if iconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, value); err == nil {
|
||||
feed.IconURL = iconURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
feed.Entries = a.populateEntries(feed.SiteURL)
|
||||
return feed
|
||||
}
|
||||
@@ -86,6 +81,16 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
}
|
||||
}
|
||||
|
||||
// If the entry has no links, attempt to use its ID as a URL
|
||||
// and if that fails, use the site URL.
|
||||
if entry.URL == "" {
|
||||
if urllib.IsAbsoluteURL(atomEntry.ID) {
|
||||
entry.URL = atomEntry.ID
|
||||
} else {
|
||||
entry.URL = siteURL
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the entry content.
|
||||
entry.Content = atomEntry.Content.body()
|
||||
if entry.Content == "" {
|
||||
@@ -109,39 +114,39 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
if len(authors) == 0 {
|
||||
authors = a.atomFeed.Authors.personNames()
|
||||
}
|
||||
sort.Strings(authors)
|
||||
authors = slices.Compact(authors)
|
||||
|
||||
entry.Author = strings.Join(authors, ", ")
|
||||
|
||||
// Populate the entry date.
|
||||
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
|
||||
if value != "" {
|
||||
if parsedDate, err := date.Parse(value); err != nil {
|
||||
slog.Debug("Unable to parse date from Atom 1.0 feed",
|
||||
slog.String("date", value),
|
||||
slog.String("url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
}
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedDate, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from Atom 1.0 feed",
|
||||
slog.String("date", value),
|
||||
slog.String("url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
}
|
||||
|
||||
if entry.Date.IsZero() {
|
||||
entry.Date = time.Now()
|
||||
}
|
||||
|
||||
// Populate categories.
|
||||
categories := atomEntry.Categories.CategoryNames()
|
||||
if len(categories) == 0 {
|
||||
categories = a.atomFeed.Categories.CategoryNames()
|
||||
entry.Tags = atomEntry.Categories.CategoryNames()
|
||||
if len(entry.Tags) == 0 {
|
||||
entry.Tags = a.atomFeed.Categories.CategoryNames()
|
||||
}
|
||||
|
||||
// Sort and deduplicate categories.
|
||||
sort.Strings(categories)
|
||||
entry.Tags = slices.Compact(categories)
|
||||
|
||||
// Populate the commentsURL if defined.
|
||||
// See https://tools.ietf.org/html/rfc4685#section-4
|
||||
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
|
||||
@@ -167,22 +172,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media thumbnail",
|
||||
slog.String("url", mediaThumbnail.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaThumbnail.MimeType(),
|
||||
Size: mediaThumbnail.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
if _, found := uniqueEnclosuresMap[mediaURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media thumbnail",
|
||||
slog.String("url", mediaThumbnail.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaThumbnail.MimeType(),
|
||||
Size: mediaThumbnail.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
|
||||
@@ -193,17 +204,21 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
|
||||
uniqueEnclosuresMap[absoluteEnclosureURL] = true
|
||||
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: absoluteEnclosureURL,
|
||||
MimeType: link.Type,
|
||||
Size: length,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
uniqueEnclosuresMap[absoluteEnclosureURL] = true
|
||||
|
||||
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: absoluteEnclosureURL,
|
||||
MimeType: link.Type,
|
||||
Size: length,
|
||||
})
|
||||
}
|
||||
|
||||
for _, mediaContent := range atomEntry.AllMediaContents() {
|
||||
@@ -211,22 +226,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
|
||||
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media content",
|
||||
slog.String("url", mediaContent.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaContent.MimeType(),
|
||||
Size: mediaContent.Size(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaContent.MimeType(),
|
||||
Size: mediaContent.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
|
||||
@@ -234,22 +255,28 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
|
||||
mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media peer link",
|
||||
slog.String("url", mediaPeerLink.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaPeerLink.MimeType(),
|
||||
Size: mediaPeerLink.Size(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaPeerLink.MimeType(),
|
||||
Size: mediaPeerLink.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
|
||||
@@ -1837,3 +1837,31 @@ func TestParseFeedWithIconURL(t *testing.T) {
|
||||
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithIDAsURL(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<link href="http://example.org/atom" rel="self"/>
|
||||
<entry>
|
||||
<id>http://www.example.org/entries/1</id>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>mailto:john.doe@example.org</id>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
|
||||
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
|
||||
}
|
||||
|
||||
if feed.Entries[1].URL != "http://example.org/" {
|
||||
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[1].URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -32,19 +34,9 @@ func (a *AtomPerson) PersonName() string {
|
||||
|
||||
type atomPersons []*AtomPerson
|
||||
|
||||
// personNames returns sorted and deduplicated author names.
|
||||
func (a atomPersons) personNames() []string {
|
||||
names := make([]string, 0, len(a))
|
||||
authorNamesMap := make(map[string]bool, len(a))
|
||||
|
||||
for _, person := range a {
|
||||
personName := person.PersonName()
|
||||
if _, ok := authorNamesMap[personName]; !ok {
|
||||
names = append(names, personName)
|
||||
authorNamesMap[personName] = true
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
return makeSorted((*AtomPerson).PersonName, a)
|
||||
}
|
||||
|
||||
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
|
||||
@@ -134,22 +126,45 @@ type atomCategory struct {
|
||||
Label string `xml:"label,attr"`
|
||||
}
|
||||
|
||||
type atomCategories []atomCategory
|
||||
|
||||
func (ac atomCategories) CategoryNames() []string {
|
||||
categories := make([]string, 0, len(ac))
|
||||
|
||||
for _, category := range ac {
|
||||
label := strings.TrimSpace(category.Label)
|
||||
if label != "" {
|
||||
categories = append(categories, label)
|
||||
} else {
|
||||
term := strings.TrimSpace(category.Term)
|
||||
if term != "" {
|
||||
categories = append(categories, term)
|
||||
}
|
||||
}
|
||||
func (ac atomCategory) name() string {
|
||||
name := strings.TrimSpace(ac.Label)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return categories
|
||||
name = strings.TrimSpace(ac.Term)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type atomCategories []atomCategory
|
||||
|
||||
// CategoryNames returns sorted and deduplicated category names.
|
||||
func (ac atomCategories) CategoryNames() []string {
|
||||
return makeSorted(atomCategory.name, ac)
|
||||
}
|
||||
|
||||
func makeSorted[I any, O cmp.Ordered](fn func(I) O, values []I) []O {
|
||||
var zero O
|
||||
|
||||
sorted := make([]O, 0, len(values))
|
||||
for _, in := range values {
|
||||
out := fn(in)
|
||||
if out == zero {
|
||||
continue
|
||||
}
|
||||
|
||||
where, found := slices.BinarySearch(sorted, out)
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert sorted to avoid duplicates.
|
||||
sorted = slices.Insert(sorted, where, out)
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
@@ -27,6 +27,6 @@ func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error)
|
||||
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
|
||||
}
|
||||
adapter := &atom10Adapter{atomFeed}
|
||||
return adapter.BuildFeed(baseURL), nil
|
||||
return adapter.buildFeed(baseURL), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ func NewRequestBuilder() *RequestBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns an independent copy of the builder. Mutating the copy (for
|
||||
// example to disable redirects for a single request) leaves the original
|
||||
// untouched.
|
||||
func (r *RequestBuilder) Clone() *RequestBuilder {
|
||||
clone := *r
|
||||
clone.headers = r.headers.Clone()
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) WithHeader(key, value string) *RequestBuilder {
|
||||
r.headers.Set(key, value)
|
||||
return r
|
||||
|
||||
@@ -267,6 +267,29 @@ func TestRequestBuilder_WithoutRedirects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_Clone(t *testing.T) {
|
||||
original := NewRequestBuilder().WithHeader("X-Shared", "value")
|
||||
|
||||
clone := original.Clone().WithoutRedirects()
|
||||
clone.WithHeader("X-Clone-Only", "value")
|
||||
|
||||
if original.withoutRedirects {
|
||||
t.Error("Mutating the clone should not disable redirects on the original")
|
||||
}
|
||||
|
||||
if original.headers.Get("X-Clone-Only") != "" {
|
||||
t.Error("Mutating the clone's headers should not affect the original")
|
||||
}
|
||||
|
||||
if clone.headers.Get("X-Shared") != "value" {
|
||||
t.Error("Expected the clone to inherit the original headers")
|
||||
}
|
||||
|
||||
if clone.clientTimeout != original.clientTimeout {
|
||||
t.Error("Expected the clone to inherit the original timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_DisableHTTP2(t *testing.T) {
|
||||
builder := NewRequestBuilder()
|
||||
builder = builder.DisableHTTP2(true)
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -41,6 +43,34 @@ type filterRule struct {
|
||||
|
||||
type filterRules []filterRule
|
||||
|
||||
const maxCachedRegexes = 1024
|
||||
|
||||
var (
|
||||
compiledRegexesCache sync.Map
|
||||
compiledRegexesCacheSize atomic.Int64
|
||||
)
|
||||
|
||||
func cachedRegex(pattern string) *regexp.Regexp {
|
||||
if v, ok := compiledRegexesCache.Load(pattern); ok {
|
||||
return v.(*regexp.Regexp)
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
slog.Warn("Failed on regexp compilation",
|
||||
slog.String("regex_pattern", pattern),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
compiledRegexesCache.Store(pattern, re)
|
||||
if compiledRegexesCacheSize.Add(1) >= maxCachedRegexes {
|
||||
compiledRegexesCache.Clear()
|
||||
compiledRegexesCacheSize.Store(0)
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
func ParseRules(userRules, feedRules string) filterRules {
|
||||
rules := make(filterRules, 0)
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(userRules), "\n") {
|
||||
@@ -103,12 +133,8 @@ func matchesEntryRegexRules(regexPattern string, feed *model.Feed, entry *model.
|
||||
return false, true // No pattern means rule is valid but doesn't match
|
||||
}
|
||||
|
||||
compiledRegex, err := regexp.Compile(regexPattern)
|
||||
if err != nil {
|
||||
slog.Warn("Failed on regexp compilation",
|
||||
slog.String("regex_pattern", regexPattern),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
compiledRegex := cachedRegex(regexPattern)
|
||||
if compiledRegex == nil {
|
||||
return false, false // Invalid regex pattern
|
||||
}
|
||||
|
||||
@@ -151,26 +177,28 @@ func matchesEntryFilterRules(rules filterRules, feed *model.Feed, entry *model.E
|
||||
}
|
||||
|
||||
func matchesRule(rule filterRule, entry *model.Entry) bool {
|
||||
switch rule.Type {
|
||||
case "EntryDate":
|
||||
if rule.Type == "EntryDate" {
|
||||
return isDateMatchingPattern(rule.Value, entry.Date)
|
||||
}
|
||||
|
||||
re := cachedRegex(rule.Value)
|
||||
if re == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case "EntryTitle":
|
||||
match, _ := regexp.MatchString(rule.Value, entry.Title)
|
||||
return match
|
||||
return re.MatchString(entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ := regexp.MatchString(rule.Value, entry.URL)
|
||||
return match
|
||||
return re.MatchString(entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ := regexp.MatchString(rule.Value, entry.CommentsURL)
|
||||
return match
|
||||
return re.MatchString(entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ := regexp.MatchString(rule.Value, entry.Content)
|
||||
return match
|
||||
return re.MatchString(entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ := regexp.MatchString(rule.Value, entry.Author)
|
||||
return match
|
||||
return re.MatchString(entry.Author)
|
||||
case "EntryTag":
|
||||
return containsRegexPattern(rule.Value, entry.Tags)
|
||||
return slices.ContainsFunc(entry.Tags, re.MatchString)
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -227,12 +255,11 @@ func isDateMatchingPattern(pattern string, entryDate time.Time) bool {
|
||||
}
|
||||
|
||||
func containsRegexPattern(pattern string, items []string) bool {
|
||||
for _, item := range items {
|
||||
if matched, _ := regexp.MatchString(pattern, item); matched {
|
||||
return true
|
||||
}
|
||||
re := cachedRegex(pattern)
|
||||
if re == nil {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(items, re.MatchString)
|
||||
}
|
||||
|
||||
func parseDuration(duration string) (time.Duration, error) {
|
||||
|
||||
@@ -95,18 +95,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
slog.String("feed_url", subscription.FeedURL),
|
||||
)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
|
||||
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(feedCreationRequest.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
|
||||
|
||||
return subscription, nil
|
||||
@@ -124,17 +112,17 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)
|
||||
requestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(feedCreationRequest.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password).
|
||||
WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(feedCreationRequest.Cookie).
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(feedCreationRequest.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy).
|
||||
IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))
|
||||
defer responseHandler.Close()
|
||||
@@ -232,22 +220,23 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
originalFeed.CheckedNow()
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(originalFeed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(originalFeed.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(originalFeed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithUsernameAndPassword(originalFeed.Username, originalFeed.Password).
|
||||
WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(originalFeed.Cookie).
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(originalFeed.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(originalFeed.FetchViaProxy).
|
||||
IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(originalFeed.DisableHTTP2)
|
||||
|
||||
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
|
||||
if !ignoreHTTPCache {
|
||||
requestBuilder.WithETag(originalFeed.EtagHeader)
|
||||
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
|
||||
requestBuilder = requestBuilder.
|
||||
WithETag(originalFeed.EtagHeader).
|
||||
WithLastModified(originalFeed.LastModifiedHeader)
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
||||
|
||||
@@ -26,16 +26,16 @@ func NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {
|
||||
}
|
||||
|
||||
func (c *iconChecker) UpdateOrCreateFeedIcon() {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(c.feed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(c.feed.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(c.feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(c.feed.Cookie).
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(c.feed.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(c.feed.FetchViaProxy).
|
||||
IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(c.feed.DisableHTTP2)
|
||||
|
||||
iconFinder := newIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
|
||||
if icon, err := iconFinder.findIcon(); err != nil {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package itunes // import "miniflux.app/v2/internal/reader/itunes"
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"iter"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
|
||||
type ItunesChannelElement struct {
|
||||
@@ -22,15 +25,16 @@ type ItunesChannelElement struct {
|
||||
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
|
||||
}
|
||||
|
||||
func (i *ItunesChannelElement) GetItunesCategories() []string {
|
||||
categories := make([]string, 0, len(i.ItunesCategories))
|
||||
for _, category := range i.ItunesCategories {
|
||||
categories = append(categories, category.Text)
|
||||
if category.SubCategory != nil {
|
||||
categories = append(categories, category.SubCategory.Text)
|
||||
func (i *ItunesChannelElement) ItunesCategoriesSeq() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for _, category := range i.ItunesCategories {
|
||||
for text := range category.All() {
|
||||
if !yield(text) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
type ItunesItemElement struct {
|
||||
@@ -56,6 +60,22 @@ type ItunesCategoryElement struct {
|
||||
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||
}
|
||||
|
||||
// All returns iterator for all category names including every nested [ItunesCategoryElement.SubCategory].
|
||||
func (cat *ItunesCategoryElement) All() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for ; cat != nil; cat = cat.SubCategory {
|
||||
text := strings.TrimSpace(cat.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !yield(text) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ItunesOwnerElement struct {
|
||||
Name string `xml:"name"`
|
||||
Email string `xml:"email"`
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package json // import "miniflux.app/v2/internal/reader/json"
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -56,32 +57,34 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||
|
||||
// Populate the icon URL if present.
|
||||
for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
|
||||
iconURL = strings.TrimSpace(iconURL)
|
||||
if iconURL != "" {
|
||||
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
|
||||
feed.IconURL = absoluteIconURL
|
||||
break
|
||||
}
|
||||
if iconURL = strings.TrimSpace(iconURL); iconURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
|
||||
feed.IconURL = absoluteIconURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range j.jsonFeed.Items {
|
||||
entry := model.NewEntry()
|
||||
entry.Title = strings.TrimSpace(item.Title)
|
||||
|
||||
for _, itemURL := range []string{item.URL, item.ExternalURL} {
|
||||
itemURL = strings.TrimSpace(itemURL)
|
||||
if itemURL != "" {
|
||||
// Make sure the entry URL is absolute.
|
||||
if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
|
||||
entry.URL = entryURL
|
||||
}
|
||||
if itemURL = strings.TrimSpace(itemURL); itemURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Make sure the entry URL is absolute.
|
||||
if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
|
||||
entry.URL = entryURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The entry title is optional, so we need to find a fallback.
|
||||
entry.Title = strings.TrimSpace(item.Title)
|
||||
if entry.Title == "" {
|
||||
// The entry title is optional, so we need to find a fallback.
|
||||
for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
|
||||
if value = sanitizer.TruncateHTML(value, 100); value == "" {
|
||||
continue
|
||||
@@ -99,75 +102,74 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||
|
||||
// Populate the entry content.
|
||||
for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
entry.Content = value
|
||||
break
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Content = value
|
||||
break
|
||||
}
|
||||
|
||||
// Populate the entry date.
|
||||
for _, value := range []string{item.DatePublished, item.DateModified} {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
if date, err := date.Parse(value); err != nil {
|
||||
slog.Debug("Unable to parse date from JSON feed",
|
||||
slog.String("date", value),
|
||||
slog.String("url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
entry.Date = date
|
||||
break
|
||||
}
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedDate, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from JSON feed",
|
||||
slog.String("date", value),
|
||||
slog.String("url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
}
|
||||
|
||||
if entry.Date.IsZero() {
|
||||
entry.Date = time.Now()
|
||||
}
|
||||
|
||||
// Populate the entry author.
|
||||
itemAuthors := j.jsonFeed.Authors
|
||||
itemAuthors = append(itemAuthors, item.Authors...)
|
||||
itemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)
|
||||
authorNames := make([]string, 0, len(j.jsonFeed.Authors)+len(item.Authors)+1+1)
|
||||
|
||||
var authorNames = make([]string, 0, len(itemAuthors))
|
||||
for _, author := range itemAuthors {
|
||||
authorName := strings.TrimSpace(author.Name)
|
||||
if authorName != "" {
|
||||
authorNames = append(authorNames, authorName)
|
||||
}
|
||||
}
|
||||
authorNames = appendSorted(authorNames, JSONAuthor.name, j.jsonFeed.Authors...)
|
||||
authorNames = appendSorted(authorNames, JSONAuthor.name, item.Authors...)
|
||||
authorNames = appendSorted(authorNames, JSONAuthor.name, item.Author, j.jsonFeed.Author)
|
||||
|
||||
slices.Sort(authorNames)
|
||||
authorNames = slices.Compact(authorNames)
|
||||
entry.Author = strings.Join(authorNames, ", ")
|
||||
|
||||
// Populate the entry enclosures.
|
||||
for _, attachment := range item.Attachments {
|
||||
attachmentURL := strings.TrimSpace(attachment.URL)
|
||||
if attachmentURL != "" {
|
||||
if absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: absoluteAttachmentURL,
|
||||
MimeType: attachment.MimeType,
|
||||
Size: attachment.Size,
|
||||
})
|
||||
}
|
||||
if attachmentURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for attachment",
|
||||
slog.String("url", attachmentURL),
|
||||
slog.String("site_url", feed.SiteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||
URL: absoluteAttachmentURL,
|
||||
MimeType: attachment.MimeType,
|
||||
Size: attachment.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// Populate the entry tags.
|
||||
for _, tag := range item.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
entry.Tags = append(entry.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and deduplicate tags.
|
||||
slices.Sort(entry.Tags)
|
||||
entry.Tags = slices.Compact(entry.Tags)
|
||||
entry.Tags = make([]string, 0, len(item.Tags))
|
||||
entry.Tags = appendSorted(entry.Tags, strings.TrimSpace, item.Tags...)
|
||||
|
||||
// Generate a hash for the entry.
|
||||
for _, value := range []string{item.ID, item.URL, item.ExternalURL, item.ContentText + item.ContentHTML + item.Summary} {
|
||||
@@ -183,3 +185,29 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
// appendSortedSeq appends elements from "values" slice into "sorted" slice.
|
||||
// - "fn" applied to every element of "values"
|
||||
// - elements inserted into "sorted" slice so it stays sorted
|
||||
// - duplicate elements are not inserted
|
||||
func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
|
||||
var zero O
|
||||
|
||||
sorted = slices.Grow(sorted, len(values))
|
||||
for in := range slices.Values(values) {
|
||||
out := fn(in)
|
||||
if out == zero {
|
||||
continue
|
||||
}
|
||||
|
||||
where, found := slices.BinarySearch(sorted, out)
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert sorted to avoid duplicates.
|
||||
sorted = slices.Insert(sorted, where, out)
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package json // import "miniflux.app/v2/internal/reader/json"
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSON Feed specs:
|
||||
// https://www.jsonfeed.org/version/1.1/
|
||||
@@ -64,6 +67,10 @@ type JSONAuthor struct {
|
||||
AvatarURL string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (a JSONAuthor) name() string {
|
||||
return strings.TrimSpace(a.Name)
|
||||
}
|
||||
|
||||
// JSONAuthors unmarshals either an array or a single author object.
|
||||
// Some feeds incorrectly use an object for "authors"; we accept it to avoid failing the whole feed.
|
||||
type JSONAuthors []JSONAuthor
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package media // import "miniflux.app/v2/internal/reader/media"
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -174,15 +175,15 @@ func (dl DescriptionList) First() string {
|
||||
|
||||
type MediaCategoryList []MediaCategory
|
||||
|
||||
func (mcl MediaCategoryList) Labels() []string {
|
||||
var labels []string
|
||||
for _, category := range mcl {
|
||||
label := strings.TrimSpace(category.Label)
|
||||
if label != "" {
|
||||
labels = append(labels, label)
|
||||
func (mcl MediaCategoryList) LabelsSeq() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for _, category := range mcl {
|
||||
label := strings.TrimSpace(category.Label)
|
||||
if !yield(label) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
type MediaCategory struct {
|
||||
|
||||
@@ -43,9 +43,9 @@ func extractBilibiliVideoID(websiteURL string) (string, string, error) {
|
||||
}
|
||||
|
||||
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
|
||||
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
|
||||
if extractErr != nil {
|
||||
|
||||
@@ -50,16 +50,16 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(feed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(feed.Cookie).
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(feed.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(feed.FetchViaProxy).
|
||||
IgnoreTLSErrors(feed.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
// Processing older entries first ensures that their creation timestamp is lower than newer entries.
|
||||
for _, entry := range slices.Backward(feed.Entries) {
|
||||
@@ -181,16 +181,16 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
startTime := time.Now()
|
||||
entry.URL = rewrite.RewriteEntryURL(feed, entry)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(feed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(feed.Cookie).
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(feed.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(feed.FetchViaProxy).
|
||||
IgnoreTLSErrors(feed.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
webpageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
)
|
||||
|
||||
func fetchWatchTime(websiteURL, query string, isoDate bool) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
@@ -92,9 +92,9 @@ func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Dura
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
|
||||
defer responseHandler.Close()
|
||||
|
||||
+147
-122
@@ -4,7 +4,9 @@
|
||||
package rss // import "miniflux.app/v2/internal/reader/rss"
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"html"
|
||||
"iter"
|
||||
"log/slog"
|
||||
"path"
|
||||
"slices"
|
||||
@@ -36,14 +38,16 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
}
|
||||
|
||||
// Try to find the feed URL from the Atom links.
|
||||
for _, atomLink := range r.rss.Channel.Links {
|
||||
atomLinkHref := strings.TrimSpace(atomLink.Href)
|
||||
if atomLinkHref != "" && atomLink.Rel == "self" {
|
||||
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {
|
||||
feed.FeedURL = absoluteFeedURL
|
||||
break
|
||||
}
|
||||
// Try to find the feed URL from the Channel links.
|
||||
for _, link := range r.rss.Channel.Links {
|
||||
href := strings.TrimSpace(link.Href)
|
||||
if href == "" || link.Rel != "self" {
|
||||
continue
|
||||
}
|
||||
|
||||
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, href); err == nil {
|
||||
feed.FeedURL = absoluteFeedURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,19 +82,20 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
|
||||
// Populate the entry URL.
|
||||
entryURL := findEntryURL(&item)
|
||||
if entryURL == "" {
|
||||
if entryURL != "" {
|
||||
entry.URL = entryURL
|
||||
if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {
|
||||
entry.URL = absoluteEntryURL
|
||||
}
|
||||
}
|
||||
|
||||
if entry.URL == "" {
|
||||
// Fallback to the feed URL if no entry URL is found.
|
||||
entry.URL = feed.SiteURL
|
||||
|
||||
// Fallback to the first enclosure URL if it exists.
|
||||
if len(entry.Enclosures) > 0 && entry.Enclosures[0].URL != "" {
|
||||
entry.URL = entry.Enclosures[0].URL
|
||||
} else {
|
||||
// Fallback to the feed URL if no entry URL is found.
|
||||
entry.URL = feed.SiteURL
|
||||
}
|
||||
} else {
|
||||
if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {
|
||||
entry.URL = absoluteEntryURL
|
||||
} else {
|
||||
entry.URL = entryURL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +155,6 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
if len(entry.Tags) == 0 {
|
||||
entry.Tags = findFeedTags(&r.rss.Channel)
|
||||
}
|
||||
// Sort and deduplicate tags.
|
||||
slices.Sort(entry.Tags)
|
||||
entry.Tags = slices.Compact(entry.Tags)
|
||||
|
||||
feed.Entries = append(feed.Entries, entry)
|
||||
}
|
||||
@@ -181,26 +183,12 @@ func findFeedAuthor(rssChannel *rssChannel) string {
|
||||
}
|
||||
|
||||
func findFeedTags(rssChannel *rssChannel) []string {
|
||||
itunesCategories := rssChannel.GetItunesCategories()
|
||||
tags := make([]string, 0, len(rssChannel.Categories)+len(itunesCategories)+1)
|
||||
tags := make([]string, 0, len(rssChannel.Categories)+2*len(rssChannel.ItunesCategories)+1)
|
||||
|
||||
for _, tag := range rssChannel.Categories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
tags = appendSorted(tags, strings.TrimSpace, rssChannel.Categories...)
|
||||
tags = appendSortedSeq(tags, strings.TrimSpace, rssChannel.ItunesCategoriesSeq())
|
||||
|
||||
for _, tag := range itunesCategories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
if tag := strings.TrimSpace(rssChannel.GooglePlayCategory.Text); tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
tags = appendSorted(tags, strings.TrimSpace, rssChannel.GooglePlayCategory.Text)
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -259,21 +247,21 @@ func findEntryDate(rssItem *rssItem) time.Time {
|
||||
value = rssItem.DublinCoreDate
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
result, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from RSS feed",
|
||||
slog.String("date", value),
|
||||
slog.String("guid", rssItem.GUID.Data),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return result
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return time.Now()
|
||||
parsedDate, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from RSS feed",
|
||||
slog.String("date", value),
|
||||
slog.String("guid", rssItem.GUID.Data),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedDate
|
||||
}
|
||||
|
||||
func findEntryAuthor(rssItem *rssItem) string {
|
||||
@@ -300,22 +288,10 @@ func findEntryAuthor(rssItem *rssItem) string {
|
||||
}
|
||||
|
||||
func findEntryTags(rssItem *rssItem) []string {
|
||||
mediaLabels := rssItem.MediaCategories.Labels()
|
||||
tags := make([]string, 0, len(rssItem.Categories)+len(mediaLabels))
|
||||
tags := make([]string, 0, len(rssItem.Categories)+len(rssItem.MediaCategories))
|
||||
|
||||
for _, tag := range rssItem.Categories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range mediaLabels {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
tags = appendSorted(tags, strings.TrimSpace, rssItem.Categories...)
|
||||
tags = appendSortedSeq(tags, strings.TrimSpace, rssItem.MediaCategories.LabelsSeq())
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -333,22 +309,28 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := duplicates[mediaURL]; !found {
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media thumbnail",
|
||||
slog.String("url", mediaThumbnail.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
duplicates[mediaAbsoluteURL] = true
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaThumbnail.MimeType(),
|
||||
Size: mediaThumbnail.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media thumbnail",
|
||||
slog.String("url", mediaThumbnail.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := duplicates[mediaURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
duplicates[mediaURL] = true
|
||||
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaURL,
|
||||
MimeType: mediaThumbnail.MimeType(),
|
||||
Size: mediaThumbnail.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, enclosure := range rssItem.Enclosures {
|
||||
@@ -370,15 +352,17 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
enclosureURL = absoluteEnclosureURL
|
||||
}
|
||||
|
||||
if _, found := duplicates[enclosureURL]; !found {
|
||||
duplicates[enclosureURL] = true
|
||||
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: enclosureURL,
|
||||
MimeType: enclosure.Type,
|
||||
Size: enclosure.Size(),
|
||||
})
|
||||
if _, found := duplicates[enclosureURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
duplicates[enclosureURL] = true
|
||||
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: enclosureURL,
|
||||
MimeType: enclosure.Type,
|
||||
Size: enclosure.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, mediaContent := range mediaContents {
|
||||
@@ -386,23 +370,28 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := duplicates[mediaURL]; !found {
|
||||
mediaURL := strings.TrimSpace(mediaContent.URL)
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media content",
|
||||
slog.String("url", mediaContent.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
duplicates[mediaAbsoluteURL] = true
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaContent.MimeType(),
|
||||
Size: mediaContent.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media content",
|
||||
slog.String("url", mediaContent.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := duplicates[mediaURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
duplicates[mediaURL] = true
|
||||
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaURL,
|
||||
MimeType: mediaContent.MimeType(),
|
||||
Size: mediaContent.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, mediaPeerLink := range mediaPeerLinks {
|
||||
@@ -410,24 +399,60 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := duplicates[mediaURL]; !found {
|
||||
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
|
||||
if mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media peer link",
|
||||
slog.String("url", mediaPeerLink.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
duplicates[mediaAbsoluteURL] = true
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaAbsoluteURL,
|
||||
MimeType: mediaPeerLink.MimeType(),
|
||||
Size: mediaPeerLink.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
mediaURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to build absolute URL for media peer link",
|
||||
slog.String("url", mediaPeerLink.URL),
|
||||
slog.String("site_url", siteURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := duplicates[mediaURL]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
duplicates[mediaURL] = true
|
||||
|
||||
enclosures = append(enclosures, &model.Enclosure{
|
||||
URL: mediaURL,
|
||||
MimeType: mediaPeerLink.MimeType(),
|
||||
Size: mediaPeerLink.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
return enclosures
|
||||
}
|
||||
|
||||
// appendSorted is identical to [appendSortedSeq] except receives variadic values rather than [iter.Seq].
|
||||
func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
|
||||
sorted = slices.Grow(sorted, len(values))
|
||||
return appendSortedSeq(sorted, fn, slices.Values(values))
|
||||
}
|
||||
|
||||
// appendSortedSeq appends elements from "values" iterator into "sorted" slice.
|
||||
// - "fn" applied to every element of "values"
|
||||
// - elements inserted into "sorted" slice so it stays sorted
|
||||
// - duplicate elements are not inserted
|
||||
func appendSortedSeq[I any, O cmp.Ordered](sorted []O, fn func(I) O, values iter.Seq[I]) []O {
|
||||
var zero O
|
||||
|
||||
for in := range values {
|
||||
out := fn(in)
|
||||
if out == zero {
|
||||
continue
|
||||
}
|
||||
|
||||
where, found := slices.BinarySearch(sorted, out)
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert sorted to avoid duplicates.
|
||||
sorted = slices.Insert(sorted, where, out)
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
@@ -1687,7 +1687,7 @@ func TestParseEntryWithMediaContent(t *testing.T) {
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
if len(feed.Entries[0].Enclosures) != 4 {
|
||||
if len(feed.Entries[0].Enclosures) != 3 {
|
||||
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||
}
|
||||
|
||||
@@ -1696,7 +1696,6 @@ func TestParseEntryWithMediaContent(t *testing.T) {
|
||||
mimeType string
|
||||
size int64
|
||||
}{
|
||||
{"https://example.org/thumbnail.jpg", "image/*", 0},
|
||||
{"https://example.org/thumbnail.jpg", "image/*", 0},
|
||||
{"https://example.org/media1.jpg", "image/*", 0},
|
||||
{"https://example.org/media2.jpg", "image/*", 0},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -18,10 +17,6 @@ import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
maxDepth = 512 // The maximum allowed depths for nested HTML tags, same was WebKit.
|
||||
)
|
||||
|
||||
var (
|
||||
allowedHTMLTagsAndAttributes = map[string][]string{
|
||||
"a": {"href", "title", "id"},
|
||||
@@ -197,8 +192,7 @@ func SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) s
|
||||
// Errors are a non-issue, so they're handled in filterAndRenderHTML
|
||||
parsedBaseUrl, _ := url.Parse(baseURL)
|
||||
for c := body.FirstChild; c != nil; c = c.NextSibling {
|
||||
// -2 because of `<html><body>…`
|
||||
if err := filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions, maxDepth-2); err != nil {
|
||||
if err := filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -224,15 +218,11 @@ func findAllowedIframeSourceDomain(iframeSourceURL string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {
|
||||
func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) error {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if depth == 0 {
|
||||
return errors.New("maximum nested tags limit reached")
|
||||
}
|
||||
|
||||
switch n.Type {
|
||||
case html.TextNode:
|
||||
buf.WriteString(html.EscapeString(n.Data))
|
||||
@@ -245,7 +235,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
_, ok := allowedHTMLTagsAndAttributes[tag]
|
||||
if !ok {
|
||||
// The tag isn't allowed, but we're still interested in its content
|
||||
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
|
||||
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
|
||||
}
|
||||
|
||||
htmlAttributes, hasAllRequiredAttributes := sanitizeAttributes(parsedBaseUrl, tag, n.Attr, sanitizerOptions)
|
||||
@@ -255,7 +245,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
return nil
|
||||
}
|
||||
// The tag doesn't have every required attributes but we're still interested in its content
|
||||
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
|
||||
return filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
|
||||
}
|
||||
buf.WriteByte('<')
|
||||
buf.WriteString(n.Data)
|
||||
@@ -271,7 +261,7 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
|
||||
if tag != "iframe" {
|
||||
// iframes aren't allowed to have child nodes.
|
||||
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)
|
||||
filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions)
|
||||
}
|
||||
|
||||
buf.WriteString("</")
|
||||
@@ -282,9 +272,9 @@ func filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {
|
||||
func filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions) error {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions, depth); err != nil {
|
||||
if err := filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,9 +1011,10 @@ func TestAttrLowerCase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeeplyNestedpage(t *testing.T) {
|
||||
maxDepth := 512 // html.Parse has a maximum depth of 512
|
||||
input := "test"
|
||||
// -3 instead of -1 because <html><body> is automatically added.
|
||||
for range maxDepth - 3 {
|
||||
// -2 instead of -1 because <html><body> is automatically added.
|
||||
for range maxDepth - 2 {
|
||||
input = "<div>" + input + "</div>"
|
||||
}
|
||||
output := sanitizeHTMLWithDefaultOptions("http://example.org/", input)
|
||||
@@ -1024,7 +1025,7 @@ func TestDeeplyNestedpage(t *testing.T) {
|
||||
}
|
||||
|
||||
input = "test"
|
||||
for range maxDepth - 2 {
|
||||
for range maxDepth - 1 {
|
||||
input = "<div>" + input + "</div>"
|
||||
}
|
||||
output = sanitizeHTMLWithDefaultOptions("http://example.org/", input)
|
||||
|
||||
@@ -221,9 +221,12 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
|
||||
// Some websites redirects unknown URLs to the home page.
|
||||
// As result, the list of known URLs is returned to the subscription list.
|
||||
// We don't want the user to choose between invalid feed URLs.
|
||||
f.requestBuilder.WithoutRedirects()
|
||||
//
|
||||
// Probe each known URL on its own builder so disabling redirects
|
||||
// here doesn't leak into the finder's other requests.
|
||||
requestBuilder := f.requestBuilder.Clone().WithoutRedirects()
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(fullURL))
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(fullURL))
|
||||
localizedError := responseHandler.LocalizedError()
|
||||
responseHandler.Close()
|
||||
|
||||
|
||||
@@ -117,14 +117,24 @@ func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model
|
||||
c.user_id,
|
||||
c.title,
|
||||
c.hide_globally,
|
||||
(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count,
|
||||
(SELECT count(*)
|
||||
FROM feeds
|
||||
JOIN entries ON (feeds.id = entries.feed_id)
|
||||
WHERE feeds.category_id = c.id AND entries.status = $1) AS count_unread
|
||||
coalesce(fc.feed_count, 0),
|
||||
coalesce(uc.unread_count, 0)
|
||||
FROM categories c
|
||||
LEFT JOIN (
|
||||
SELECT category_id, count(*) AS feed_count
|
||||
FROM feeds
|
||||
WHERE user_id = $2
|
||||
GROUP BY category_id
|
||||
) fc ON fc.category_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT f.category_id, count(*) AS unread_count
|
||||
FROM entries e
|
||||
INNER JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE e.user_id = $2 AND e.status = $1
|
||||
GROUP BY f.category_id
|
||||
) uc ON uc.category_id = c.id
|
||||
WHERE
|
||||
user_id=$2
|
||||
c.user_id=$2
|
||||
`
|
||||
|
||||
if sortOrder == "alphabetical" {
|
||||
@@ -135,7 +145,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model
|
||||
} else {
|
||||
query += `
|
||||
ORDER BY
|
||||
count_unread DESC,
|
||||
coalesce(uc.unread_count, 0) DESC,
|
||||
c.title ASC
|
||||
`
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetEnclosures returns all attachments for the given entry.
|
||||
func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
|
||||
// EnclosuresByEntryID returns all enclosures for the given entry.
|
||||
func (s *Storage) EnclosuresByEntryID(entryID int64) (model.EnclosureList, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -61,7 +61,8 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
|
||||
return enclosures, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetEnclosuresForEntries(entryIDs []int64) (map[int64]model.EnclosureList, error) {
|
||||
// EnclosuresByEntryIDs returns enclosures for the given entries, grouped by entry ID.
|
||||
func (s *Storage) EnclosuresByEntryIDs(entryIDs []int64) (map[int64]model.EnclosureList, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -106,7 +107,8 @@ func (s *Storage) GetEnclosuresForEntries(entryIDs []int64) (map[int64]model.Enc
|
||||
return enclosuresMap, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
|
||||
// EnclosureByID returns the enclosure for the given user and enclosure ID.
|
||||
func (s *Storage) EnclosureByID(userID, enclosureID int64) (*model.Enclosure, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
@@ -119,10 +121,10 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
|
||||
FROM
|
||||
enclosures
|
||||
WHERE
|
||||
id = $1
|
||||
id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
row := s.db.QueryRow(query, enclosureID)
|
||||
row := s.db.QueryRow(query, enclosureID, userID)
|
||||
|
||||
var enclosure model.Enclosure
|
||||
err := row.Scan(
|
||||
@@ -155,7 +157,7 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
|
||||
(url, size, mime_type, entry_id, user_id, media_progression)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING
|
||||
ON CONFLICT (user_id, entry_id, encode(sha256(url::bytea), 'hex')) DO NOTHING
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
@@ -215,6 +217,7 @@ func (s *Storage) updateEnclosures(tx *sql.Tx, entry *model.Entry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEnclosure persists changes to the given enclosure.
|
||||
func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
|
||||
query := `
|
||||
UPDATE
|
||||
|
||||
@@ -451,20 +451,10 @@ func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64
|
||||
// SetEntriesStarredState updates the starred state for the given list of entries.
|
||||
func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
|
||||
query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
|
||||
result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
|
||||
if err != nil {
|
||||
if _, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs)); err != nil {
|
||||
return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return errors.New(`store: nothing has been updated`)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ type entryPaginationBuilder struct {
|
||||
// WithSearchQuery adds full-text search query to the condition.
|
||||
func (e *entryPaginationBuilder) WithSearchQuery(query string) *entryPaginationBuilder {
|
||||
if query != "" {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", len(e.args)+1))
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ websearch_to_tsquery($%d)", len(e.args)+1))
|
||||
e.args = append(e.args, query)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ func (e *EntryQueryBuilder) WithoutContent() *EntryQueryBuilder {
|
||||
func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
|
||||
if query != "" {
|
||||
nArgs := len(e.args) + 1
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", nArgs))
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ websearch_to_tsquery($%d)", nArgs))
|
||||
e.args = append(e.args, query)
|
||||
|
||||
// 0.0000001 = 0.1 / (seconds_in_a_day)
|
||||
|
||||
e.sortExpressions = append(e.sortExpressions,
|
||||
fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
|
||||
fmt.Sprintf("ts_rank(document_vectors, websearch_to_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
|
||||
)
|
||||
}
|
||||
return e
|
||||
@@ -207,6 +207,14 @@ func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithLimitAndMaximum sets the limit, capped at the given maximum.
|
||||
func (e *EntryQueryBuilder) WithLimitAndMaximum(limit, maximum int) *EntryQueryBuilder {
|
||||
if limit > 0 {
|
||||
e.limit = min(limit, maximum)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// WithOffset set the offset.
|
||||
func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder {
|
||||
if offset > 0 {
|
||||
@@ -250,7 +258,7 @@ func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries[0].Enclosures, err = e.store.GetEnclosures(entries[0].ID)
|
||||
entries[0].Enclosures, err = e.store.EnclosuresByEntryID(entries[0].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +433,7 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
|
||||
}
|
||||
|
||||
if e.fetchEnclosures && len(entryIDs) > 0 {
|
||||
enclosures, err := e.store.GetEnclosuresForEntries(entryIDs)
|
||||
enclosures, err := e.store.EnclosuresByEntryIDs(entryIDs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: unable to fetch enclosures: %w", err)
|
||||
}
|
||||
@@ -462,18 +470,32 @@ func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
|
||||
var entryIDs []int64
|
||||
for rows.Next() {
|
||||
var entryID int64
|
||||
|
||||
err := rows.Scan(&entryID)
|
||||
if err != nil {
|
||||
if err := rows.Scan(&entryID); err != nil {
|
||||
return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
|
||||
}
|
||||
|
||||
entryIDs = append(entryIDs, entryID)
|
||||
}
|
||||
|
||||
return entryIDs, nil
|
||||
}
|
||||
|
||||
// GetEntryIDsWithCount returns a list of entry IDs and the total count of
|
||||
// matching rows (ignoring limit/offset). It uses two queries: one to count
|
||||
// all matching rows and one to fetch the paginated IDs.
|
||||
func (e *EntryQueryBuilder) GetEntryIDsWithCount() ([]int64, int, error) {
|
||||
total, err := e.CountEntries()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
entryIDs, err := e.GetEntryIDs()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return entryIDs, total, nil
|
||||
}
|
||||
|
||||
func (e *EntryQueryBuilder) contentColumn() string {
|
||||
if e.excludeContent {
|
||||
return "'' AS content"
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var dummyBcryptHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
|
||||
|
||||
// CountUsers returns the total number of users.
|
||||
func (s *Storage) CountUsers() (int, error) {
|
||||
var result int
|
||||
@@ -673,6 +675,8 @@ func (s *Storage) CheckPassword(username, password string) error {
|
||||
|
||||
err := s.db.QueryRow("SELECT password FROM users WHERE username=$1", username).Scan(&hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Perform a dummy bcrypt comparison against the hashed `password` string to avoid leaking whether the user exists via response timing.
|
||||
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
|
||||
return fmt.Errorf(`store: unable to find this user: %s`, username)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf(`store: unable to fetch user: %v`, err)
|
||||
|
||||
@@ -28,7 +28,7 @@ type Engine struct {
|
||||
func NewEngine(basePath string) *Engine {
|
||||
return &Engine{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: &funcMap{basePath},
|
||||
funcMap: &funcMap{basePath: basePath},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,17 @@ import (
|
||||
)
|
||||
|
||||
type funcMap struct {
|
||||
basePath string
|
||||
basePath string
|
||||
iconPaths map[string]string
|
||||
}
|
||||
|
||||
// Map returns a map of template functions that are compiled during template parsing.
|
||||
func (f *funcMap) Map() template.FuncMap {
|
||||
// Pre-compute every icon URL once, as iconPath is called a lot during pages rendering.
|
||||
f.iconPaths = make(map[string]string, len(static.BinaryBundles))
|
||||
for filename, bundle := range static.BinaryBundles {
|
||||
f.iconPaths[filename] = f.basePath + "/icon/" + bundle.Checksum + "/" + filename
|
||||
}
|
||||
return template.FuncMap{
|
||||
"contains": strings.Contains,
|
||||
"csp": csp,
|
||||
@@ -155,10 +161,7 @@ func (f *funcMap) Map() template.FuncMap {
|
||||
}
|
||||
|
||||
func (f *funcMap) iconPath(filename string) string {
|
||||
if bundle, ok := static.BinaryBundles[filename]; ok {
|
||||
return fmt.Sprintf("%s/icon/%s/%s", f.basePath, bundle.Checksum, filename)
|
||||
}
|
||||
return fmt.Sprintf("%s/icon/_/%s", f.basePath, filename)
|
||||
return f.iconPaths[filename]
|
||||
}
|
||||
|
||||
func (f *funcMap) iconFunc() func(string) template.HTML {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ range .feeds }}
|
||||
<article
|
||||
class="item feed-item {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ else if ne .UnreadCount 0 }}feed-has-unread{{ end }}"
|
||||
aria-labelledby="feed-title-{{ .ID }} feed-entries-counter"
|
||||
aria-labelledby="feed-title-{{ .ID }} feed-entries-counter-{{ .ID }}"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="item-header" dir="auto">
|
||||
@@ -16,7 +16,7 @@
|
||||
{{ .Title }}
|
||||
</a>
|
||||
</h2>
|
||||
<span id="feed-entries-counter" class="feed-entries-counter">
|
||||
<span id="feed-entries-counter-{{ .ID }}" class="feed-entries-counter">
|
||||
<span aria-hidden="true">(</span>
|
||||
<span class="sr-only">{{ plural "page.unread_entry_count" .UnreadCount .UnreadCount }}</span>
|
||||
<span aria-hidden="true">{{ .UnreadCount }} /</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -56,9 +56,9 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
WithSearchQuery(searchQuery)
|
||||
if unreadOnly {
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
entryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)
|
||||
entryPaginationBuilder = entryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)
|
||||
} else {
|
||||
entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
|
||||
entryPaginationBuilder = entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,15 +54,17 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent())
|
||||
|
||||
feedModificationRequest := &model.FeedModificationRequest{
|
||||
FeedURL: model.OptionalString(feedForm.FeedURL),
|
||||
SiteURL: model.OptionalString(feedForm.SiteURL),
|
||||
Title: model.OptionalString(feedForm.Title),
|
||||
Description: model.OptionalString(feedForm.Description),
|
||||
CategoryID: model.OptionalNumber(feedForm.CategoryID),
|
||||
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
|
||||
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
|
||||
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
|
||||
ProxyURL: model.OptionalString(feedForm.ProxyURL),
|
||||
FeedURL: model.OptionalString(feedForm.FeedURL),
|
||||
SiteURL: model.OptionalString(feedForm.SiteURL),
|
||||
Title: model.OptionalString(feedForm.Title),
|
||||
Description: model.OptionalString(feedForm.Description),
|
||||
CategoryID: model.OptionalNumber(feedForm.CategoryID),
|
||||
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
|
||||
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
|
||||
UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),
|
||||
ProxyURL: model.OptionalString(feedForm.ProxyURL),
|
||||
BlockFilterEntryRules: model.OptionalString(feedForm.BlockFilterEntryRules),
|
||||
KeepFilterEntryRules: model.OptionalString(feedForm.KeepFilterEntryRules),
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feed.ID, feedModificationRequest); validationErr != nil {
|
||||
|
||||
@@ -57,10 +57,22 @@ func (s *SubscriptionForm) Validate() *locale.LocalizedError {
|
||||
return locale.NewLocalizedError("error.feed_invalid_urlrewrite_rule")
|
||||
}
|
||||
|
||||
if s.ProxyURL != "" && !urllib.IsAbsoluteURL(s.ProxyURL) {
|
||||
if s.ProxyURL != "" && !urllib.IsValidProxyURL(s.ProxyURL) {
|
||||
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
|
||||
}
|
||||
|
||||
if s.BlockFilterEntryRules != "" {
|
||||
if err := validator.IsValidFilterRules(s.BlockFilterEntryRules, "block"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if s.KeepFilterEntryRules != "" {
|
||||
if err := validator.IsValidFilterRules(s.KeepFilterEntryRules, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubscriptionFormValidateInvalidBlockFilterRules(t *testing.T) {
|
||||
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, BlockFilterEntryRules: "BadField=foo"}
|
||||
if err := s.Validate(); err == nil {
|
||||
t.Error("Validate should return an error for an invalid block filter rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionFormValidateInvalidKeepFilterRules(t *testing.T) {
|
||||
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, KeepFilterEntryRules: "BadField=foo"}
|
||||
if err := s.Validate(); err == nil {
|
||||
t.Error("Validate should return an error for an invalid keep filter rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionFormValidateValidFilterRules(t *testing.T) {
|
||||
s := &SubscriptionForm{URL: "https://example.com/feed", CategoryID: 1, BlockFilterEntryRules: "EntryTitle=add"}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Errorf("Validate should not return an error for a valid filter rule, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,9 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
|
||||
view.Set("countUnread", navMetadata.CountUnread)
|
||||
view.Set("countErrorFeeds", navMetadata.CountErrorFeeds)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(opmlFileURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
@@ -86,20 +86,18 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("media_url", mediaURL),
|
||||
)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.MediaProxyHTTPClientTimeout())
|
||||
|
||||
// Disable compression for the media proxy requests (not implemented).
|
||||
requestBuilder.WithoutCompression()
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.MediaProxyHTTPClientTimeout()).
|
||||
WithoutCompression() // Disable compression for the media proxy requests (not implemented).
|
||||
|
||||
if referer := rewrite.GetRefererForURL(mediaURL); referer != "" {
|
||||
requestBuilder.WithHeader("Referer", referer)
|
||||
requestBuilder = requestBuilder.WithHeader("Referer", referer)
|
||||
}
|
||||
|
||||
forwardedRequestHeader := [...]string{"Range", "Accept", "Accept-Encoding", "User-Agent"}
|
||||
for _, requestHeaderName := range forwardedRequestHeader {
|
||||
if r.Header.Get(requestHeaderName) != "" {
|
||||
requestBuilder.WithHeader(requestHeaderName, r.Header.Get(requestHeaderName))
|
||||
requestBuilder = requestBuilder.WithHeader(requestHeaderName, r.Header.Get(requestHeaderName))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
|
||||
WithLimit(user.EntriesPerPage)
|
||||
|
||||
if unreadOnly {
|
||||
builder.WithStatuses(model.EntryStatusUnread)
|
||||
builder = builder.WithStatuses(model.EntryStatusUnread)
|
||||
}
|
||||
|
||||
entries, entriesCount, err = builder.GetEntriesWithCount()
|
||||
|
||||
@@ -658,19 +658,28 @@ function toggleEntryStatus(element, toasting) {
|
||||
/**
|
||||
* Handle the refresh of all feeds.
|
||||
*
|
||||
* This function POSTs to the URL specified in the data-refresh-all-feeds-url attribute of the body element.
|
||||
* This submits a real form POST to the URL specified in the data-refresh-all-feeds-url
|
||||
* attribute of the body element, so the browser follows the redirect once and renders the
|
||||
* server-side flash message, matching the behavior of the menu button.
|
||||
*/
|
||||
function handleRefreshAllFeedsAction() {
|
||||
const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl;
|
||||
if (refreshAllFeedsUrl) {
|
||||
sendPOSTRequest(refreshAllFeedsUrl).then((response) => {
|
||||
if (response?.redirected && response.url) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
if (!refreshAllFeedsUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = refreshAllFeedsUrl;
|
||||
|
||||
const csrfField = document.createElement("input");
|
||||
csrfField.type = "hidden";
|
||||
csrfField.name = "csrf";
|
||||
csrfField.value = document.body.dataset.csrfToken || "";
|
||||
form.appendChild(csrfField);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,17 +57,17 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
rssBridgeToken = intg.RSSBridgeToken
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(subscriptionForm.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(subscriptionForm.FetchViaProxy)
|
||||
requestBuilder.WithUserAgent(subscriptionForm.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(subscriptionForm.Cookie)
|
||||
requestBuilder.WithUsernameAndPassword(subscriptionForm.Username, subscriptionForm.Password)
|
||||
requestBuilder.IgnoreTLSErrors(subscriptionForm.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(subscriptionForm.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(subscriptionForm.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(subscriptionForm.FetchViaProxy).
|
||||
WithUserAgent(subscriptionForm.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(subscriptionForm.Cookie).
|
||||
WithUsernameAndPassword(subscriptionForm.Username, subscriptionForm.Password).
|
||||
IgnoreTLSErrors(subscriptionForm.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(subscriptionForm.DisableHTTP2)
|
||||
|
||||
subscriptionFinder := subscription.NewSubscriptionFinder(requestBuilder)
|
||||
subscriptions, localizedError := subscriptionFinder.FindSubscriptions(
|
||||
|
||||
@@ -43,6 +43,11 @@ func hasHTTPPrefix(inputURL string) bool {
|
||||
return strings.HasPrefix(inputURL, "https://") || strings.HasPrefix(inputURL, "http://")
|
||||
}
|
||||
|
||||
// hasSOCKSPrefix reports whether the URL string begins with an SOCKS5 or SOCKS5H scheme.
|
||||
func hasSOCKSPrefix(inputURL string) bool {
|
||||
return strings.HasPrefix(inputURL, "socks5://") || strings.HasPrefix(inputURL, "socks5h://")
|
||||
}
|
||||
|
||||
// IsAbsoluteURL reports whether the link is absolute and starts with an HTTP or HTTPS scheme.
|
||||
func IsAbsoluteURL(inputURL string) bool {
|
||||
if !hasHTTPPrefix(inputURL) {
|
||||
@@ -55,6 +60,18 @@ func IsAbsoluteURL(inputURL string) bool {
|
||||
return parsedURL.IsAbs()
|
||||
}
|
||||
|
||||
// IsValidProxyURL reports whether the url is absolute, has a host and starts with an HTTP, HTTPS, SOCKS5 or SOCKS5H scheme.
|
||||
func IsValidProxyURL(inputURL string) bool {
|
||||
if !hasHTTPPrefix(inputURL) && !hasSOCKSPrefix(inputURL) {
|
||||
return false
|
||||
}
|
||||
parsedURL, err := url.Parse(inputURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parsedURL.IsAbs() && parsedURL.Host != ""
|
||||
}
|
||||
|
||||
// resolveToAbsoluteURL resolves a relative URL using a base URL, parsing the base only if needed.
|
||||
func resolveToAbsoluteURL(parsedBaseURL *url.URL, baseURL, relativeURL string) (string, error) {
|
||||
// Avoid parsing the relative URL if it's already absolute
|
||||
|
||||
@@ -68,6 +68,34 @@ func TestIsAbsoluteURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidProxyURL(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"http://127.0.0.1:3128": true,
|
||||
"http://[::1]:1055": true,
|
||||
"https://proxy.example.org": true,
|
||||
"socks5://127.0.0.1:1080": true,
|
||||
"socks5h://127.0.0.1:1080": true,
|
||||
"socks5://[::1]:1055": true,
|
||||
"socks5h://[::1]:1055": true,
|
||||
"socks5://[::1%25eno1]:1055": true,
|
||||
"ftp://host": false,
|
||||
"/relative/path": false,
|
||||
"invalid url": false,
|
||||
"http://[::1": false,
|
||||
"http:///var/run/socket": false,
|
||||
"sock:///socket.file": false,
|
||||
"socks5://[::1%eno1]:1055": false,
|
||||
"": false,
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
actual := IsValidProxyURL(input)
|
||||
if actual != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v for %q`, actual, expected, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteURL(t *testing.T) {
|
||||
type absoluteScenario struct {
|
||||
name string
|
||||
|
||||
@@ -19,6 +19,25 @@ func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateReques
|
||||
return ValidateEntryStatus(request.Status)
|
||||
}
|
||||
|
||||
// ValidateEntriesStatusAndStarredUpdateRequest validates a status and/or starred update
|
||||
// for a list of entries. At least one of the status or starred fields must be specified.
|
||||
// This is used by the API, which can update the read status, the starred state, or both.
|
||||
func ValidateEntriesStatusAndStarredUpdateRequest(request *model.EntriesStatusUpdateRequest) error {
|
||||
if len(request.EntryIDs) == 0 {
|
||||
return errors.New(`the list of entries cannot be empty`)
|
||||
}
|
||||
|
||||
if request.Status == "" && request.Starred == nil {
|
||||
return errors.New(`either the status or the starred field must be specified`)
|
||||
}
|
||||
|
||||
if request.Status != "" {
|
||||
return ValidateEntryStatus(request.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEntryStatus makes sure the entry status is valid.
|
||||
func ValidateEntryStatus(status string) error {
|
||||
switch status {
|
||||
|
||||
@@ -34,6 +34,74 @@ func TestValidateEntriesStatusUpdateRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntriesStatusAndStarredUpdateRequest(t *testing.T) {
|
||||
err := ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Status: model.EntryStatusRead,
|
||||
EntryIDs: []int64{int64(123), int64(456)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(`A valid request should not be rejected`)
|
||||
}
|
||||
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Status: model.EntryStatusRead,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error(`An empty list of entries is not valid`)
|
||||
}
|
||||
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Status: "invalid",
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error(`Only a valid status should be accepted`)
|
||||
}
|
||||
|
||||
starred := true
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Starred: &starred,
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(`A request with only the starred field should be accepted`)
|
||||
}
|
||||
|
||||
notStarred := false
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Starred: ¬Starred,
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(`A request with starred set to false should be accepted`)
|
||||
}
|
||||
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Status: model.EntryStatusRead,
|
||||
Starred: &starred,
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(`A request with both status and starred should be accepted`)
|
||||
}
|
||||
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error(`A request without status or starred should be rejected`)
|
||||
}
|
||||
|
||||
err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
|
||||
Status: "invalid",
|
||||
Starred: &starred,
|
||||
EntryIDs: []int64{int64(123)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error(`An invalid status should be rejected even when starred is specified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntryStatus(t *testing.T) {
|
||||
for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
|
||||
if err := ValidateEntryStatus(status); err != nil {
|
||||
|
||||
@@ -37,18 +37,18 @@ func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.F
|
||||
}
|
||||
|
||||
if request.BlockFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(request.BlockFilterEntryRules, "block"); err != nil {
|
||||
if err := IsValidFilterRules(request.BlockFilterEntryRules, "block"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if request.KeepFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(request.KeepFilterEntryRules, "keep"); err != nil {
|
||||
if err := IsValidFilterRules(request.KeepFilterEntryRules, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if request.ProxyURL != "" && !urllib.IsAbsoluteURL(request.ProxyURL) {
|
||||
if request.ProxyURL != "" && !urllib.IsValidProxyURL(request.ProxyURL) {
|
||||
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
|
||||
}
|
||||
|
||||
@@ -106,13 +106,13 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ
|
||||
}
|
||||
|
||||
if request.BlockFilterEntryRules != nil && *request.BlockFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(*request.BlockFilterEntryRules, "block"); err != nil {
|
||||
if err := IsValidFilterRules(*request.BlockFilterEntryRules, "block"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if request.KeepFilterEntryRules != nil && *request.KeepFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(*request.KeepFilterEntryRules, "keep"); err != nil {
|
||||
if err := IsValidFilterRules(*request.KeepFilterEntryRules, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ
|
||||
return locale.NewLocalizedError("error.proxy_url_not_empty")
|
||||
}
|
||||
|
||||
if !urllib.IsAbsoluteURL(*request.ProxyURL) {
|
||||
if !urllib.IsValidProxyURL(*request.ProxyURL) {
|
||||
return locale.NewLocalizedError("error.invalid_feed_proxy_url")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"miniflux.app/v2/internal/locale"
|
||||
)
|
||||
|
||||
func isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
|
||||
func IsValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
|
||||
// Valid Format: FieldName=RegEx\nFieldName=RegEx...
|
||||
fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag", "EntryDate"}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIsValidFilterRules(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
tc := tt
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := isValidFilterRules(tc.rules, "block")
|
||||
err := IsValidFilterRules(tc.rules, "block")
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("expected error=%v, got %v", tc.wantErr, err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func ValidateSubscriptionDiscovery(request *model.SubscriptionDiscoveryRequest)
|
||||
return locale.NewLocalizedError("error.invalid_site_url")
|
||||
}
|
||||
|
||||
if request.ProxyURL != "" && !urllib.IsAbsoluteURL(request.ProxyURL) {
|
||||
if request.ProxyURL != "" && !urllib.IsValidProxyURL(request.ProxyURL) {
|
||||
return locale.NewLocalizedError("error.invalid_proxy_url")
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
|
||||
|
||||
if changes.BlockFilterEntryRules != nil {
|
||||
if *changes.BlockFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(*changes.BlockFilterEntryRules, "block"); err != nil {
|
||||
if err := IsValidFilterRules(*changes.BlockFilterEntryRules, "block"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
|
||||
|
||||
if changes.KeepFilterEntryRules != nil {
|
||||
if *changes.KeepFilterEntryRules != "" {
|
||||
if err := isValidFilterRules(*changes.KeepFilterEntryRules, "keep"); err != nil {
|
||||
if err := IsValidFilterRules(*changes.KeepFilterEntryRules, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func init() {
|
||||
if Version == "" {
|
||||
// Some Miniflux clients expect a specific version format.
|
||||
// For example, Flux News converts the string version to an integer.
|
||||
Version = "2.2.x-dev"
|
||||
Version = "2.3.x-dev"
|
||||
}
|
||||
if Commit == "" {
|
||||
Commit = getCommit()
|
||||
|
||||
+2
-2
@@ -546,7 +546,7 @@ a linked OAuth2 account to sign in\&.
|
||||
Disabled by default\&.
|
||||
.TP
|
||||
.B POLLING_FREQUENCY
|
||||
Interval for the background job scheduler.
|
||||
Interval in minutes for the background job scheduler.
|
||||
.br
|
||||
Determines how often a batch of feeds is selected for refresh,
|
||||
based on their last refresh time\&.
|
||||
@@ -625,7 +625,7 @@ Minimum interval in minutes for the round robin scheduler\&.
|
||||
Default is 60 minutes\&.
|
||||
.TP
|
||||
.B TRUSTED_REVERSE_PROXY_NETWORKS
|
||||
List of networks (CIDR notation) allowed to use the proxy
|
||||
A comma-separated list of networks (CIDR notation) allowed to use the proxy
|
||||
authentication header, \fBX-Forwarded-For\fR,
|
||||
\fBX-Forwarded-Proto\fR, and \fBX-Real-Ip\fR headers\&.
|
||||
.br
|
||||
|
||||
Reference in New Issue
Block a user