Compare commits
74 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 | |||
| 9a774083ab | |||
| 79381e6f9a | |||
| 161ed71eb0 | |||
| c896bafdaa | |||
| 66996e3ffa | |||
| f050f23bda | |||
| 50088405e0 | |||
| d235a63138 | |||
| 483da488d8 | |||
| e01a6100ca | |||
| a725476164 | |||
| 47e304f343 | |||
| 9dfed35946 | |||
| 95f5f1e77d | |||
| a08f598cc8 | |||
| 0e1523551b | |||
| eff9502462 | |||
| ab94f42ad1 | |||
| f66772e911 | |||
| 43456daddf | |||
| 863d6039ad | |||
| fc3e548be7 | |||
| 096a15ef14 | |||
| 0976efd163 | |||
| 06e36c3e54 | |||
| 39772c33f0 | |||
| 098270cb14 | |||
| 24c65304a7 | |||
| 3747e686af | |||
| cc1d39efe1 | |||
| bdd7f4f365 | |||
| 7628a214f3 |
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -74,8 +74,6 @@ type UserModificationRequest struct {
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
@@ -148,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"`
|
||||
@@ -174,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.
|
||||
@@ -187,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"`
|
||||
@@ -207,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"`
|
||||
@@ -222,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"`
|
||||
@@ -320,6 +332,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
Tags []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
@@ -329,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,21 +8,21 @@ 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/go-webauthn/webauthn v0.17.3
|
||||
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.51.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.54.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 (
|
||||
github.com/go-webauthn/x v0.2.5 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
)
|
||||
@@ -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.44.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=
|
||||
@@ -19,10 +19,10 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
|
||||
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
|
||||
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
|
||||
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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))
|
||||
@@ -163,15 +168,14 @@ func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := h.store.NewBatchBuilder().
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithUserID(userID).
|
||||
WithCategoryID(categoryID).
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
+112
-53
@@ -55,9 +55,9 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithFeedID(feedID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -75,9 +75,9 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithCategoryID(categoryID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -161,25 +161,25 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
|
||||
tags := request.QueryStringParamList(r, "tags")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithStatuses(statuses)
|
||||
builder.WithSorting(order, direction)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
builder := h.store.NewEntryQueryBuilder(userID).
|
||||
WithFeedID(feedID).
|
||||
WithCategoryID(categoryID).
|
||||
WithStatuses(statuses...).
|
||||
WithSorting(order, direction).
|
||||
WithOffset(offset).
|
||||
WithLimit(limit).
|
||||
WithTags(tags...).
|
||||
WithEnclosures()
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
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)
|
||||
@@ -236,15 +246,14 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
if !h.store.HasSaveEntry(request.UserID(r)) {
|
||||
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -285,10 +294,10 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
loggedUserID := request.UserID(r)
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -445,10 +454,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -470,9 +478,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
|
||||
WithFeedID(entry.FeedID).
|
||||
GetFeed()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -499,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
|
||||
}
|
||||
|
||||
@@ -76,14 +76,13 @@ func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := h.store.NewBatchBuilder().
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithUserID(userID).
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -116,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`)
|
||||
|
||||
@@ -20,14 +20,13 @@ func refreshFeeds(store *storage.Storage) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(config.Opts.BatchSize())
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := store.NewBatchBuilder().
|
||||
WithBatchSize(config.Opts.BatchSize()).
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
return
|
||||
@@ -36,7 +35,7 @@ func refreshFeeds(store *storage.Storage) {
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
nbJobs := len(jobs)
|
||||
var jobQueue = make(chan model.Job, nbJobs)
|
||||
jobQueue := make(chan model.Job, nbJobs)
|
||||
|
||||
slog.Info("Starting a pool of workers",
|
||||
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
|
||||
|
||||
@@ -33,14 +33,15 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(frequency) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(batchSize)
|
||||
batchBuilder.WithErrorLimit(errorLimit)
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(limitPerHost)
|
||||
jobs, err := store.NewBatchBuilder().
|
||||
WithBatchSize(batchSize).
|
||||
WithErrorLimit(errorLimit).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(limitPerHost).
|
||||
FetchJobs()
|
||||
|
||||
if jobs, err := batchBuilder.FetchJobs(); err != nil {
|
||||
if err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
} else if len(jobs) > 0 {
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1510,4 +1519,30 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// entries_feed_idx is redundant: the unique constraint
|
||||
// entries_feed_id_hash_key(feed_id, hash) and the explicit
|
||||
// entries_feed_id_status_hash_idx(feed_id, status, hash) both
|
||||
// cover feed_id-leading lookups, including FK cascade deletes.
|
||||
//
|
||||
// entries_user_status_idx is redundant: five three-column indexes
|
||||
// share the same (user_id, status) prefix and serve every query
|
||||
// that the two-column index could.
|
||||
_, err = tx.Exec(`
|
||||
DROP INDEX IF EXISTS entries_feed_idx;
|
||||
DROP INDEX IF EXISTS entries_user_status_idx;
|
||||
`)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
+19
-21
@@ -238,8 +238,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID := request.UserID(r)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithLimit(50)
|
||||
builder := h.store.NewEntryQueryBuilder(userID).
|
||||
WithLimit(50)
|
||||
|
||||
switch {
|
||||
case request.HasQueryParam(r, "since_id"):
|
||||
@@ -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",
|
||||
@@ -292,8 +292,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder = h.store.NewEntryQueryBuilder(userID)
|
||||
result.Total, err = builder.CountEntries()
|
||||
result.Total, err = h.store.NewEntryQueryBuilder(userID).
|
||||
CountEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -342,9 +342,9 @@ func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request)
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
rawEntryIDs, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithStatuses(model.EntryStatusUnread).
|
||||
GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -375,10 +375,9 @@ func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request)
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithStarred(true)
|
||||
|
||||
entryIDs, err := builder.GetEntryIDs()
|
||||
entryIDs, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithStarred(true).
|
||||
GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -410,10 +409,9 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -236,20 +236,19 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
|
||||
slog.Any("tags", tags),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
entries, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEntryIDs(itemIDs...).
|
||||
GetEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
n := 0
|
||||
readEntryIDs := make([]int64, 0)
|
||||
unreadEntryIDs := make([]int64, 0)
|
||||
starredEntryIDs := make([]int64, 0)
|
||||
unstarredEntryIDs := make([]int64, 0)
|
||||
var readEntryIDs []int64
|
||||
var unreadEntryIDs []int64
|
||||
var starredEntryIDs []int64
|
||||
var unstarredEntryIDs []int64
|
||||
for _, entry := range entries {
|
||||
if read, exists := tags[ReadStream]; exists {
|
||||
if read && entry.Status == model.EntryStatusUnread {
|
||||
@@ -343,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
|
||||
@@ -649,12 +649,11 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
|
||||
slog.Any("item_ids", itemIDs),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEnclosures()
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
entries, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEnclosures().
|
||||
WithEntryIDs(itemIDs...).
|
||||
WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection).
|
||||
GetEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -1012,11 +1011,15 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder = builder.WithStatuses(model.EntryStatusUnread)
|
||||
default:
|
||||
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
|
||||
slog.String("handler", "handleReadingListStreamHandler"),
|
||||
@@ -1027,14 +1030,12 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
}
|
||||
}
|
||||
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.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)
|
||||
@@ -1046,36 +1047,42 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithStarred(true)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithStarred(true).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
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)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithStatuses(model.EntryStatusRead).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
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)
|
||||
@@ -1083,6 +1090,7 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
@@ -1091,7 +1099,7 @@ func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestMod
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
|
||||
itemRefs := make([]itemRef, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
@@ -1115,32 +1123,32 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithFeedID(feedID).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
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))
|
||||
}
|
||||
|
||||
if len(rm.ExcludeTargets) > 0 {
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
if s.Type == ReadStream {
|
||||
builder.WithoutStatus(model.EntryStatusRead)
|
||||
}
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
if s.Type == ReadStream {
|
||||
builder = builder.WithoutStatus(model.EntryStatusRead)
|
||||
}
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +6,10 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -23,14 +25,14 @@ type Builder struct {
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
headers http.Header
|
||||
enableCompression bool
|
||||
body any
|
||||
}
|
||||
|
||||
// NewBuilder creates a new response builder.
|
||||
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
@@ -41,7 +43,7 @@ func (b *Builder) WithStatus(statusCode int) *Builder {
|
||||
|
||||
// WithHeader adds the given HTTP header to the response.
|
||||
func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
b.headers[key] = value
|
||||
b.headers.Set(key, value)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -65,13 +67,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
|
||||
|
||||
// WithAttachment forces the document to be downloaded by the web browser.
|
||||
func (b *Builder) WithAttachment(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = formatContentDisposition("attachment", filename)
|
||||
b.headers.Set("Content-Disposition", formatContentDisposition("attachment", filename))
|
||||
return b
|
||||
}
|
||||
|
||||
// WithInline suggests an inline filename for the current response.
|
||||
func (b *Builder) WithInline(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
|
||||
b.headers.Set("Content-Disposition", formatContentDisposition("inline", filename))
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -84,9 +86,11 @@ func (b *Builder) WithoutCompression() *Builder {
|
||||
// WithCaching adds caching headers to the response.
|
||||
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
||||
etag = normalizeETag(etag)
|
||||
b.headers["ETag"] = etag
|
||||
b.headers["Cache-Control"] = "public, immutable"
|
||||
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
|
||||
b.headers.Set("ETag", etag)
|
||||
// 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) {
|
||||
b.statusCode = http.StatusNotModified
|
||||
@@ -120,24 +124,22 @@ func (b *Builder) Write() {
|
||||
}
|
||||
|
||||
func (b *Builder) writeHeaders() {
|
||||
b.headers["X-Content-Type-Options"] = "nosniff"
|
||||
b.headers["X-Frame-Options"] = "DENY"
|
||||
b.headers["Referrer-Policy"] = "no-referrer"
|
||||
b.headers.Set("X-Content-Type-Options", "nosniff")
|
||||
b.headers.Set("X-Frame-Options", "DENY")
|
||||
b.headers.Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
for key, value := range b.headers {
|
||||
b.w.Header().Set(key, value)
|
||||
}
|
||||
maps.Copy(b.w.Header(), b.headers)
|
||||
|
||||
b.w.WriteHeader(b.statusCode)
|
||||
}
|
||||
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
b.headers["Vary"] = "Accept-Encoding"
|
||||
b.headers.Set("Vary", "Accept-Encoding")
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.headers.Set("Content-Encoding", "br")
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
@@ -145,7 +147,7 @@ func (b *Builder) compress(data []byte) {
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.headers.Set("Content-Encoding", "gzip")
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
@@ -153,7 +155,7 @@ func (b *Builder) compress(data []byte) {
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.headers.Set("Content-Encoding", "deflate")
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -224,6 +224,9 @@ func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
|
||||
"file:///etc/passwd",
|
||||
"mailto:victim@example.org",
|
||||
"//evil.example.org/path",
|
||||
`/\evil.example.org/path`,
|
||||
`\evil.example.org\path`,
|
||||
`/foo\bar`,
|
||||
"ftp://example.org/file",
|
||||
"",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -54,7 +55,11 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
|
||||
// Both checks have to be run to avoid leaking informations
|
||||
// about the username and the password.
|
||||
usernameCorrect := crypto.ConstantTimeCmp(username, config.Opts.MetricsUsername())
|
||||
passwordCorrect := crypto.ConstantTimeCmp(password, config.Opts.MetricsPassword())
|
||||
if !usernameCorrect || !passwordCorrect {
|
||||
slog.Warn("Metrics endpoint accessed with invalid username or password",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (c *Client) attachTags(entryID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
tagItems := make([]tagItem, 0)
|
||||
tagItems := make([]tagItem, 0, strings.Count(c.tags, ",")+1)
|
||||
for tag := range strings.SplitSeq(c.tags, ",") {
|
||||
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
|
||||
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
|
||||
|
||||
@@ -88,7 +88,7 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -111,6 +111,7 @@ func TestTranslationFilePluralForms(t *testing.T) {
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"ko_KR": 1,
|
||||
"nan_Latn_pehoeji": 1,
|
||||
"nl_NL": 2,
|
||||
"pl_PL": 3,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ var AvailableLanguages = map[string]string{
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"ko_KR": "한국어",
|
||||
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
|
||||
"nl_NL": "Nederlands",
|
||||
"pl_PL": "Polski",
|
||||
|
||||
@@ -36,7 +36,7 @@ func getPluralForm(lang string, n int) int {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "id_ID", "ja_JP":
|
||||
case "id_ID", "ja_JP", "ko_KR":
|
||||
return 0
|
||||
case "pl_PL":
|
||||
switch {
|
||||
|
||||
@@ -66,6 +66,14 @@ func TestPluralRules(t *testing.T) {
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Korean (ko_KR) - always form 0
|
||||
"ko_KR": {
|
||||
0: 0,
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Polish (pl_PL) - 3 forms
|
||||
"pl_PL": {
|
||||
1: 0, // n == 1
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
{
|
||||
"action.cancel": "취소",
|
||||
"action.download": "다운로드",
|
||||
"action.edit": "편집",
|
||||
"action.home_screen": "홈 화면에 추가",
|
||||
"action.import": "가져오기",
|
||||
"action.login": "로그인",
|
||||
"action.or": "또는",
|
||||
"action.remove": "삭제",
|
||||
"action.remove_feed": "이 피드 삭제",
|
||||
"action.save": "저장",
|
||||
"action.subscribe": "피드 구독",
|
||||
"action.update": "업데이트",
|
||||
"alert.account_linked": "외부 계정과 연동되었습니다!",
|
||||
"alert.account_unlinked": "외부 계정과의 연동이 해제되었습니다!",
|
||||
"alert.background_feed_refresh": "모든 피드를 백그라운드에서 새로 고치는 중입니다. 이 작업 중에도 Miniflux를 계속 사용할 수 있습니다.",
|
||||
"alert.feed_error": "이 피드에 문제가 있습니다.",
|
||||
"alert.no_starred": "현재 즐겨찾기 표시된 게시물이 없습니다.",
|
||||
"alert.no_category": "카테고리가 없습니다.",
|
||||
"alert.no_category_entry": "이 카테고리에는 게시물이 없습니다.",
|
||||
"alert.no_feed": "구독 중인 피드가 없습니다.",
|
||||
"alert.no_feed_entry": "이 피드에는 게시물이 없습니다.",
|
||||
"alert.no_feed_in_category": "이 카테고리에는 구독 중인 피드가 없습니다.",
|
||||
"alert.no_history": "현재 기록이 없습니다.",
|
||||
"alert.no_search_result": "검색 결과가 없습니다.",
|
||||
"alert.no_shared_entry": "공유된 게시물이 없습니다.",
|
||||
"alert.no_tag_entry": "이 태그와 일치하는 게시물이 없습니다.",
|
||||
"alert.no_unread_entry": "읽지 않은 게시물이 없습니다.",
|
||||
"alert.no_user": "당신이 유일한 사용자입니다.",
|
||||
"alert.prefs_saved": "설정이 정상적으로 저장되었습니다!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"피드 새로고침 요청이 너무 많습니다. %d분 후 다시 시도해 주세요."
|
||||
],
|
||||
"confirm.loading": "실행 중…",
|
||||
"confirm.no": "아니요",
|
||||
"confirm.question": "진행하시겠습니까?",
|
||||
"confirm.question.refresh": "강제로 새로 고치시겠습니까?",
|
||||
"confirm.yes": "예",
|
||||
"enclosure_media_controls.seek": "탐색:",
|
||||
"enclosure_media_controls.seek.title": "%s초 이동",
|
||||
"enclosure_media_controls.speed": "속도:",
|
||||
"enclosure_media_controls.speed.faster": "빠르게",
|
||||
"enclosure_media_controls.speed.faster.title": "%sx 빠르게",
|
||||
"enclosure_media_controls.speed.reset": "초기화",
|
||||
"enclosure_media_controls.speed.reset.title": "속도를 1x로 초기화",
|
||||
"enclosure_media_controls.speed.slower": "느리게",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx 느리게",
|
||||
"entry.starred.toast.off": "즐겨찾기를 해제했습니다",
|
||||
"entry.starred.toast.on": "즐겨찾기로 설정했습니다",
|
||||
"entry.starred.toggle.off": "즐겨찾기 해제",
|
||||
"entry.starred.toggle.on": "즐겨찾기 설정",
|
||||
"entry.comments.label": "댓글",
|
||||
"entry.comments.title": "댓글 보기",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d분 소요"
|
||||
],
|
||||
"entry.external_link.label": "외부 링크",
|
||||
"entry.save.completed": "완료!",
|
||||
"entry.save.label": "저장",
|
||||
"entry.save.title": "이 게시물 저장",
|
||||
"entry.save.toast.completed": "게시물이 저장되었습니다",
|
||||
"entry.scraper.completed": "완료!",
|
||||
"entry.scraper.label": "다운로드",
|
||||
"entry.scraper.title": "원본 내용 가져오기",
|
||||
"entry.share.label": "공유",
|
||||
"entry.share.title": "이 게시물 공유",
|
||||
"entry.shared_entry.label": "공유하기",
|
||||
"entry.shared_entry.title": "공개 링크 열기",
|
||||
"entry.state.loading": "불러오는 중…",
|
||||
"entry.state.saving": "저장 중…",
|
||||
"entry.status.mark_as_read": "읽음으로 표시",
|
||||
"entry.status.mark_as_unread": "읽지 않음으로 표시",
|
||||
"entry.status.title": "게시물 상태 변경",
|
||||
"entry.status.toast.read": "읽음으로 표시했습니다",
|
||||
"entry.status.toast.unread": "읽지 않음으로 표시했습니다",
|
||||
"entry.tags.label": "태그:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"태그 %d개"
|
||||
],
|
||||
"entry.unshare.label": "공유 해제",
|
||||
"error.api_key_already_exists": "이 API 키는 이미 존재합니다.",
|
||||
"error.bad_credentials": "사용자명 또는 비밀번호가 잘못되었습니다.",
|
||||
"error.category_already_exists": "이 카테고리는 이미 존재합니다.",
|
||||
"error.category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.database_error": "데이터베이스 오류: %v.",
|
||||
"error.different_passwords": "비밀번호가 일치하지 않습니다.",
|
||||
"error.duplicate_fever_username": "같은 Fever 사용자명이 이미 사용 중입니다!",
|
||||
"error.duplicate_googlereader_username": "같은 Google Reader 사용자명이 이미 사용 중입니다!",
|
||||
"error.duplicate_linked_account": "다른 사용자가 이미 이 서비스의 동일한 사용자와 연동되어 있습니다.",
|
||||
"error.duplicated_feed": "이 피드는 이미 존재합니다.",
|
||||
"error.empty_file": "이 파일은 비어 있습니다.",
|
||||
"error.entries_per_page_invalid": "페이지당 게시물 수가 유효하지 않습니다.",
|
||||
"error.feed_already_exists": "이 피드는 이미 존재합니다.",
|
||||
"error.feed_category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.feed_format_not_detected": "피드 형식을 감지할 수 없습니다: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "차단 목록 규칙이 유효하지 않습니다.",
|
||||
"error.feed_invalid_keeplist_rule": "허용 목록 규칙이 유효하지 않습니다.",
|
||||
"error.feed_mandatory_fields": "URL과 카테고리가 필요합니다.",
|
||||
"error.feed_not_found": "이 피드는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.feed_title_not_empty": "피드 제목은 비워 둘 수 없습니다.",
|
||||
"error.feed_url_not_empty": "피드 URL은 비워 둘 수 없습니다.",
|
||||
"error.fields_mandatory": "모든 항목을 입력해주세요.",
|
||||
"error.http_bad_gateway": "잘못된 게이트웨이 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 나중에 다시 시도해 주세요.",
|
||||
"error.http_body_read": "HTTP 본문을 읽을 수 없습니다: %v.",
|
||||
"error.http_client_error": "HTTP 클라이언트 오류: %v.",
|
||||
"error.http_cloudflare_challenge": "이 웹사이트는 Cloudflare 봇 챌린지(CAPTCHA 또는 JavaScript 검증)로 보호되어 있습니다. Miniflux는 이 챌린지를 자동으로 해결할 수 없습니다.",
|
||||
"error.http_empty_response": "HTTP 응답이 비어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
|
||||
"error.http_empty_response_body": "HTTP 응답 본문이 비어 있습니다.",
|
||||
"error.http_forbidden": "이 웹사이트에 대한 접근이 금지되어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
|
||||
"error.http_gateway_timeout": "게이트웨이 시간 초과로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_internal_server_error": "서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_not_authorized": "이 웹사이트에 대한 접근이 허용되지 않았습니다. 사용자명 또는 비밀번호가 올바르지 않을 수 있습니다.",
|
||||
"error.http_resource_not_found": "요청한 리소스를 찾을 수 없습니다. URL을 확인해 주세요.",
|
||||
"error.http_response_too_large": "HTTP 응답이 너무 큽니다. 전역 설정에서 HTTP 응답 크기 제한을 늘릴 수 있습니다. (서버 재시작 필요)",
|
||||
"error.http_service_unavailable": "내부 서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. 문제는 Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_too_many_requests": "Miniflux가 이 웹사이트에 너무 많은 요청을 보냈습니다. 잠시 기다리거나 애플리케이션 설정을 변경해 주세요.",
|
||||
"error.http_unexpected_status_code": "예상치 못한 HTTP 상태 코드(%d)로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.invalid_categories_sorting_order": "카테고리 표시 순서가 유효하지 않습니다.",
|
||||
"error.invalid_default_home_page": "기본 시작 페이지가 유효하지 않습니다",
|
||||
"error.invalid_display_mode": "웹 앱 표시 모드가 유효하지 않습니다.",
|
||||
"error.invalid_entry_direction": "게시물 표시 방향이 유효하지 않습니다.",
|
||||
"error.invalid_entry_order": "게시물 표시 순서가 유효하지 않습니다.",
|
||||
"error.invalid_feed_proxy_url": "프록시 URL이 유효하지 않습니다.",
|
||||
"error.invalid_feed_url": "피드 URL이 유효하지 않습니다.",
|
||||
"error.invalid_gesture_nav": "제스처 내비게이션이 유효하지 않습니다.",
|
||||
"error.invalid_language": "언어가 유효하지 않습니다.",
|
||||
"error.invalid_site_url": "사이트 URL이 유효하지 않습니다.",
|
||||
"error.invalid_theme": "테마가 유효하지 않습니다.",
|
||||
"error.invalid_timezone": "시간대가 유효하지 않습니다.",
|
||||
"error.network_operation": "네트워크 오류로 인해 Miniflux가 이 웹사이트에 도달할 수 없습니다: %v.",
|
||||
"error.network_timeout": "이 웹사이트의 응답이 너무 느려 시간 초과되었습니다: %v",
|
||||
"error.password_min_length": "비밀번호는 6자 이상이어야 합니다.",
|
||||
"error.proxy_url_not_empty": "프록시 URL은 비워 둘 수 없습니다.",
|
||||
"error.settings_block_rule_fieldname_invalid": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
|
||||
"error.settings_block_rule_regex_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
|
||||
"error.settings_block_rule_separator_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
|
||||
"error.settings_invalid_domain_list": "도메인 목록이 유효하지 않습니다. 도메인은 공백으로 구분해 지정해 주세요.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
|
||||
"error.settings_keep_rule_regex_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
|
||||
"error.settings_keep_rule_separator_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
|
||||
"error.settings_mandatory_fields": "사용자명, 테마, 언어, 시간대가 모두 필요합니다.",
|
||||
"error.settings_media_playback_rate_range": "재생 속도가 범위를 벗어났습니다",
|
||||
"error.settings_reading_speed_is_positive": "읽기 속도는 양의 정수여야 합니다.",
|
||||
"error.site_url_not_empty": "사이트 URL은 비워 둘 수 없습니다.",
|
||||
"error.subscription_not_found": "피드를 찾을 수 없습니다.",
|
||||
"error.title_required": "제목이 필요합니다.",
|
||||
"error.tls_error": "TLS 오류: %q. 필요한 경우 피드 설정에서 TLS 검증을 비활성화할 수 있습니다.",
|
||||
"error.unable_to_create_api_key": "이 API 키를 만들 수 없습니다.",
|
||||
"error.unable_to_create_category": "이 카테고리를 만들 수 없습니다.",
|
||||
"error.unable_to_create_user": "이 사용자를 만들 수 없습니다.",
|
||||
"error.unable_to_detect_rssbridge": "RSS-Bridge를 사용해 피드를 감지할 수 없습니다: %v.",
|
||||
"error.unable_to_parse_feed": "이 피드를 파싱할 수 없습니다: %v.",
|
||||
"error.unable_to_update_category": "이 카테고리를 업데이트할 수 없습니다.",
|
||||
"error.unable_to_update_feed": "이 피드를 업데이트할 수 없습니다.",
|
||||
"error.unable_to_update_user": "이 사용자를 업데이트할 수 없습니다.",
|
||||
"error.unlink_account_without_password": "비밀번호를 설정하지 않으면 다시 로그인할 수 없습니다.",
|
||||
"error.user_already_exists": "이 사용자는 이미 존재합니다.",
|
||||
"error.user_mandatory_fields": "사용자명이 필요합니다.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API 토큰과 조직 슬러그가 필요합니다",
|
||||
"form.api_key.label.description": "API키 설명",
|
||||
"form.category.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
|
||||
"form.category.label.title": "제목",
|
||||
"form.feed.fieldset.general": "일반",
|
||||
"form.feed.fieldset.integration": "서드파티 서비스",
|
||||
"form.feed.fieldset.network_settings": "네트워크 설정",
|
||||
"form.feed.fieldset.rules": "규칙",
|
||||
"form.feed.label.allow_self_signed_certificates": "자체 서명 인증서 또는 유효하지 않은 인증서 허용",
|
||||
"form.feed.label.apprise_service_urls": "Apprise 서비스 URL의 쉼표로 구분된 목록",
|
||||
"form.feed.label.block_filter_entry_rules": "게시물 차단 규칙",
|
||||
"form.feed.label.blocklist_rules": "정규식 기반 차단 필터",
|
||||
"form.feed.label.category": "카테고리",
|
||||
"form.feed.label.cookie": "Cookie 설정",
|
||||
"form.feed.label.crawler": "게시물 본문도 함께 다운로드",
|
||||
"form.feed.label.ignore_entry_updates": "업데이트된 게시물 무시",
|
||||
"form.feed.label.description": "설명",
|
||||
"form.feed.label.disable_http2": "핑거프린팅 회피를 위해 HTTP/2 비활성화",
|
||||
"form.feed.label.disabled": "이 피드를 업데이트하지 않음",
|
||||
"form.feed.label.feed_password": "피드 비밀번호",
|
||||
"form.feed.label.feed_url": "피드 URL",
|
||||
"form.feed.label.feed_username": "피드 사용자명",
|
||||
"form.feed.label.fetch_via_proxy": "애플리케이션 수준에서 설정된 프록시 사용",
|
||||
"form.feed.label.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
|
||||
"form.feed.label.ignore_http_cache": "HTTP 캐시 무시",
|
||||
"form.feed.label.keep_filter_entry_rules": "게시물 허용 규칙",
|
||||
"form.feed.label.keeplist_rules": "정규식 기반 보존 필터",
|
||||
"form.feed.label.no_media_player": "미디어 기능 비활성화 (오디오/비디오)",
|
||||
"form.feed.label.ntfy_activate": "게시물을 ntfy로 전송",
|
||||
"form.feed.label.ntfy_default_priority": "ntfy 기본 우선순위",
|
||||
"form.feed.label.ntfy_high_priority": "ntfy 높은 우선순위",
|
||||
"form.feed.label.ntfy_low_priority": "ntfy 낮은 우선순위",
|
||||
"form.feed.label.ntfy_max_priority": "ntfy 최대 우선순위",
|
||||
"form.feed.label.ntfy_min_priority": "ntfy 최소 우선순위",
|
||||
"form.feed.label.ntfy_priority": "ntfy 우선순위",
|
||||
"form.feed.label.ntfy_topic": "ntfy 토픽(선택 사항)",
|
||||
"form.feed.label.proxy_url": "프록시 URL",
|
||||
"form.feed.label.pushover_activate": "게시물을 pushover.net으로 전송",
|
||||
"form.feed.label.pushover_default_priority": "Pushover 기본 우선순위",
|
||||
"form.feed.label.pushover_high_priority": "Pushover 높은 우선순위",
|
||||
"form.feed.label.pushover_low_priority": "Pushover 낮은 우선순위",
|
||||
"form.feed.label.pushover_max_priority": "Pushover 최대 우선순위",
|
||||
"form.feed.label.pushover_min_priority": "Pushover 최소 우선순위",
|
||||
"form.feed.label.pushover_priority": "Pushover 메시지 우선순위",
|
||||
"form.feed.label.rewrite_rules": "본문 재작성 규칙",
|
||||
"form.feed.label.scraper_rules": "본문 추출 규칙",
|
||||
"form.feed.label.site_url": "사이트 URL",
|
||||
"form.feed.label.title": "제목",
|
||||
"form.feed.label.urlrewrite_rules": "URL 재작성 규칙",
|
||||
"form.feed.label.user_agent": "기본 User Agent 덮어쓰기",
|
||||
"form.feed.label.webhook_url": "Webhook URL 덮어쓰기",
|
||||
"form.import.label.file": "OPML 파일",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "게시물을 archive.org로 푸시",
|
||||
"form.integration.apprise_activate": "게시물을 Apprise로 전송",
|
||||
"form.integration.apprise_services_url": "Apprise 서비스 URL의 쉼표로 구분된 목록",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.betula_activate": "게시물을 Betula에 저장",
|
||||
"form.integration.betula_token": "Betula 토큰",
|
||||
"form.integration.betula_url": "Betula 서버 URL",
|
||||
"form.integration.cubox_activate": "게시물을 Cubox에 저장",
|
||||
"form.integration.cubox_api_link": "Cubox API 링크",
|
||||
"form.integration.discord_activate": "게시물을 Discord로 전송",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook 링크",
|
||||
"form.integration.espial_activate": "Espial에 게시물 저장",
|
||||
"form.integration.espial_api_key": "Espial API 키",
|
||||
"form.integration.espial_endpoint": "Espial API 엔드포인트",
|
||||
"form.integration.espial_tags": "Espial 태그",
|
||||
"form.integration.fever_activate": "Fever API 활성화",
|
||||
"form.integration.fever_endpoint": "Fever API 엔드포인트:",
|
||||
"form.integration.fever_password": "Fever 비밀번호",
|
||||
"form.integration.fever_username": "Fever 사용자명",
|
||||
"form.integration.googlereader_activate": "Google Reader API 활성화",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
|
||||
"form.integration.googlereader_password": "Google Reader 비밀번호",
|
||||
"form.integration.googlereader_username": "Google Reader 사용자명",
|
||||
"form.integration.instapaper_activate": "Instapaper에 게시물 저장",
|
||||
"form.integration.instapaper_password": "Instapaper 비밀번호",
|
||||
"form.integration.instapaper_username": "Instapaper 사용자명",
|
||||
"form.integration.karakeep_activate": "Karakeep에 게시물 저장",
|
||||
"form.integration.karakeep_api_key": "Karakeep API 키",
|
||||
"form.integration.karakeep_url": "Karakeep API 엔드포인트",
|
||||
"form.integration.karakeep_tags": "Karakeep 태그",
|
||||
"form.integration.linkace_activate": "게시물을 LinkAce에 저장",
|
||||
"form.integration.linkace_api_key": "LinkAce API 키",
|
||||
"form.integration.linkace_check_disabled": "링크 확인 비활성화",
|
||||
"form.integration.linkace_endpoint": "LinkAce API 엔드포인트",
|
||||
"form.integration.linkace_is_private": "링크를 비공개로 설정",
|
||||
"form.integration.linkace_tags": "LinkAce 태그",
|
||||
"form.integration.linkding_activate": "Linkding에 게시물 저장",
|
||||
"form.integration.linkding_api_key": "Linkding API 키",
|
||||
"form.integration.linkding_bookmark": "북마크를 읽지 않음으로 표시",
|
||||
"form.integration.linkding_endpoint": "Linkding API 엔드포인트",
|
||||
"form.integration.linkding_tags": "Linkding 태그",
|
||||
"form.integration.linktaco_activate": "LinkTaco에 게시물 저장",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API 토큰",
|
||||
"form.integration.linktaco_api_token_hint": "개인용 액세스 토큰 받기",
|
||||
"form.integration.linktaco_org_slug": "조직 슬러그",
|
||||
"form.integration.linktaco_tags": "태그(최대 10개, 쉼표로 구분)",
|
||||
"form.integration.linktaco_tags_hint": "최대 10개의 태그, 쉼표로 구분",
|
||||
"form.integration.linktaco_visibility": "공개 설정",
|
||||
"form.integration.linktaco_visibility_public": "공개",
|
||||
"form.integration.linktaco_visibility_private": "비공개",
|
||||
"form.integration.linktaco_visibility_hint": "비공개 설정에는 유료 LinkTaco 계정이 필요합니다",
|
||||
"form.integration.linkwarden_activate": "Linkwarden에 게시물 저장",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 키",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 기본 URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden 컬렉션 ID",
|
||||
"form.integration.matrix_bot_activate": "새 게시물을 Matrix로 전달",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 룸 ID",
|
||||
"form.integration.matrix_bot_password": "Matrix 사용자 비밀번호",
|
||||
"form.integration.matrix_bot_url": "Matrix 서버 URL",
|
||||
"form.integration.matrix_bot_user": "Matrix 사용자명",
|
||||
"form.integration.notion_activate": "게시물을 Notion에 저장",
|
||||
"form.integration.notion_page_id": "Notion 페이지 ID",
|
||||
"form.integration.notion_token": "Notion 시크릿 토큰",
|
||||
"form.integration.ntfy_activate": "게시물을 ntfy로 전송",
|
||||
"form.integration.ntfy_api_token": "ntfy API 토큰(선택 사항)",
|
||||
"form.integration.ntfy_icon_url": "ntfy 아이콘 URL(선택 사항)",
|
||||
"form.integration.ntfy_internal_links": "클릭 시 내부 링크 사용(선택 사항)",
|
||||
"form.integration.ntfy_password": "ntfy 비밀번호(선택 사항)",
|
||||
"form.integration.ntfy_topic": "ntfy 토픽(피드에 설정되어 있지 않으면 기본값)",
|
||||
"form.integration.ntfy_url": "ntfy URL(선택 사항, 기본값 ntfy.sh)",
|
||||
"form.integration.ntfy_username": "ntfy 사용자명(선택 사항)",
|
||||
"form.integration.nunux_keeper_activate": "Nunux Keeper에 게시물 저장",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 키",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 엔드포인트",
|
||||
"form.integration.omnivore_activate": "Omnivore에 게시물 저장",
|
||||
"form.integration.omnivore_api_key": "Omnivore API 키",
|
||||
"form.integration.omnivore_url": "Omnivore API 엔드포인트",
|
||||
"form.integration.pinboard_activate": "Pinboard에 게시물 저장",
|
||||
"form.integration.pinboard_bookmark": "북마크를 읽지 않음으로 표시",
|
||||
"form.integration.pinboard_tags": "Pinboard 태그",
|
||||
"form.integration.pinboard_token": "Pinboard API 토큰",
|
||||
"form.integration.pushover_activate": "게시물을 Pushover로 전송",
|
||||
"form.integration.pushover_device": "Pushover 기기(선택 사항)",
|
||||
"form.integration.pushover_prefix": "Pushover URL 접두사(선택 사항)",
|
||||
"form.integration.pushover_token": "Pushover 앱 API 토큰",
|
||||
"form.integration.pushover_user": "Pushover 사용자 키",
|
||||
"form.integration.raindrop_activate": "게시물을 Raindrop에 저장",
|
||||
"form.integration.raindrop_collection_id": "컬렉션 ID",
|
||||
"form.integration.raindrop_tags": "태그(쉼표로 구분)",
|
||||
"form.integration.raindrop_token": "(테스트) 토큰",
|
||||
"form.integration.readeck_activate": "Readeck에 게시물 저장",
|
||||
"form.integration.readeck_api_key": "Readeck API 키",
|
||||
"form.integration.readeck_endpoint": "Readeck API 엔드포인트",
|
||||
"form.integration.readeck_labels": "Readeck 라벨",
|
||||
"form.integration.readeck_only_url": "URL만 전송(전체 콘텐츠가 아님)",
|
||||
"form.integration.readeck_push_activate": "새 게시물을 자동으로 Readeck에 전송",
|
||||
"form.integration.readwise_activate": "Readwise Reader에 게시물 저장",
|
||||
"form.integration.readwise_api_key": "Readwise Reader 액세스 토큰",
|
||||
"form.integration.readwise_api_key_link": "Readwise 액세스 토큰 받기",
|
||||
"form.integration.rssbridge_activate": "구독 추가 시 RSS-Bridge 확인",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge 인증 토큰",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 서버 URL",
|
||||
"form.integration.shaarli_activate": "게시물을 Shaarli에 저장",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API 시크릿",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shiori_activate": "게시물을 Shiori에 저장",
|
||||
"form.integration.shiori_endpoint": "Shiori API 엔드포인트",
|
||||
"form.integration.shiori_password": "Shiori 비밀번호",
|
||||
"form.integration.shiori_username": "Shiori 사용자명",
|
||||
"form.integration.slack_activate": "게시물을 Slack으로 전송",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook 링크",
|
||||
"form.integration.telegram_bot_activate": "새 게시물을 Telegram 채팅으로 푸시",
|
||||
"form.integration.telegram_bot_disable_buttons": "버튼 비활성화",
|
||||
"form.integration.telegram_bot_disable_notification": "알림 비활성화",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "웹 페이지 미리보기 비활성화",
|
||||
"form.integration.telegram_bot_token": "봇 토큰",
|
||||
"form.integration.telegram_chat_id": "채팅 ID",
|
||||
"form.integration.telegram_topic_id": "토픽 ID",
|
||||
"form.integration.wallabag_activate": "Wallabag에 게시물 저장",
|
||||
"form.integration.wallabag_client_id": "Wallabag 클라이언트 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 클라이언트 시크릿",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 기본 URL",
|
||||
"form.integration.wallabag_only_url": "URL만 전송(전체 콘텐츠가 아님)",
|
||||
"form.integration.wallabag_password": "Wallabag 비밀번호",
|
||||
"form.integration.wallabag_username": "Wallabag 사용자명",
|
||||
"form.integration.wallabag_tags": "Wallabag 태그",
|
||||
"form.integration.webhook_activate": "Webhook 활성화",
|
||||
"form.integration.webhook_secret": "Webhook 시크릿",
|
||||
"form.integration.webhook_url": "기본 Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "애플리케이션 설정",
|
||||
"form.prefs.fieldset.authentication_settings": "비밀번호 인증",
|
||||
"form.prefs.fieldset.google_authentication": "Google 인증",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s 인증",
|
||||
"form.prefs.fieldset.global_feed_settings": "전역 피드 설정",
|
||||
"form.prefs.fieldset.reader_settings": "리더 설정",
|
||||
"form.prefs.help.external_font_hosts": "허용할 외부 폰트 호스트를 공백으로 구분해 지정합니다. 예: \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
"form.prefs.label.always_open_external_links": "외부 링크를 열어 게시물 읽기",
|
||||
"form.prefs.label.categories_sorting_order": "카테고리 표시 순서",
|
||||
"form.prefs.label.cjk_reading_speed": "한국어, 일본어, 중국어 읽기 속도 (문자/분)",
|
||||
"form.prefs.label.custom_css": "사용자 지정 CSS",
|
||||
"form.prefs.label.custom_js": "사용자 지정 JavaScript",
|
||||
"form.prefs.label.default_home_page": "기본 시작 페이지",
|
||||
"form.prefs.label.default_reading_speed": "다른 언어의 읽기 속도(단어/분)",
|
||||
"form.prefs.label.display_mode": "프로그레시브 웹 앱(PWA) 표시 모드",
|
||||
"form.prefs.label.entries_per_page": "페이지당 게시물 수",
|
||||
"form.prefs.label.entry_order": "게시물 표시 순서 기준",
|
||||
"form.prefs.label.entry_sorting": "게시물 표시 순서",
|
||||
"form.prefs.label.entry_swipe": "터치스크린에서 스와이프 입력 활성화",
|
||||
"form.prefs.label.external_font_hosts": "외부 폰트 호스트",
|
||||
"form.prefs.label.gesture_nav": "게시물 간 이동 제스처",
|
||||
"form.prefs.label.keyboard_shortcuts": "키보드 단축키 활성화",
|
||||
"form.prefs.label.language": "언어",
|
||||
"form.prefs.label.mark_read_manually": "수동으로 읽음 처리",
|
||||
"form.prefs.label.mark_read_on_media_completion": "오디오/비디오 재생이 90%%에 도달하면 읽음 처리",
|
||||
"form.prefs.label.mark_read_on_view": "표시할 때 게시물을 자동으로 읽음으로 표시",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "표시할 때 읽음 처리. 오디오/비디오는 90%% 재생 시 읽음 처리",
|
||||
"form.prefs.label.media_playback_rate": "오디오/비디오 재생 속도",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "외부 링크를 새 탭에서 열기(링크에 target=\"_blank\" 추가)",
|
||||
"form.prefs.label.show_reading_time": "게시물 예상 읽기 시간 표시",
|
||||
"form.prefs.label.theme": "테마",
|
||||
"form.prefs.label.timezone": "시간대",
|
||||
"form.prefs.select.alphabetical": "알파벳순",
|
||||
"form.prefs.select.browser": "브라우저형",
|
||||
"form.prefs.select.created_time": "게시물 가져온 시각",
|
||||
"form.prefs.select.fullscreen": "전체 화면",
|
||||
"form.prefs.select.minimal_ui": "미니멀 UI",
|
||||
"form.prefs.select.none": "없음",
|
||||
"form.prefs.select.older_first": "오래된 게시물 먼저",
|
||||
"form.prefs.select.publish_time": "게시물 공개 시각",
|
||||
"form.prefs.select.recent_first": "새 게시물 먼저",
|
||||
"form.prefs.select.standalone": "독립형",
|
||||
"form.prefs.select.swipe": "스와이프",
|
||||
"form.prefs.select.tap": "더블 탭",
|
||||
"form.prefs.select.unread_count": "읽지 않은 항목 수",
|
||||
"form.submit.loading": "불러오는 중…",
|
||||
"form.submit.saving": "저장 중…",
|
||||
"form.user.label.admin": "관리자",
|
||||
"form.user.label.confirmation": "비밀번호 확인",
|
||||
"form.user.label.password": "비밀번호",
|
||||
"form.user.label.username": "사용자명",
|
||||
"menu.about": "소프트웨어 정보",
|
||||
"menu.add_feed": "피드 구독",
|
||||
"menu.add_user": "사용자 추가",
|
||||
"menu.api_keys": "API 키",
|
||||
"menu.categories": "카테고리",
|
||||
"menu.create_api_key": "새 API 키 만들기",
|
||||
"menu.create_category": "카테고리 만들기",
|
||||
"menu.edit_category": "편집",
|
||||
"menu.edit_feed": "편집",
|
||||
"menu.export": "내보내기",
|
||||
"menu.feed_entries": "게시물 목록",
|
||||
"menu.feeds": "피드 목록",
|
||||
"menu.flush_history": "기록 지우기",
|
||||
"menu.history": "기록",
|
||||
"menu.home_page": "홈페이지",
|
||||
"menu.import": "가져오기",
|
||||
"menu.integrations": "연동",
|
||||
"menu.logout": "로그아웃",
|
||||
"menu.mark_all_as_read": "모두 읽음으로 표시",
|
||||
"menu.mark_page_as_read": "이 페이지를 읽음으로 표시",
|
||||
"menu.preferences": "설정 정보",
|
||||
"menu.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
|
||||
"menu.refresh_feed": "새로고침",
|
||||
"menu.search": "검색",
|
||||
"menu.sessions": "세션",
|
||||
"menu.settings": "설정",
|
||||
"menu.shared_entries": "공유 게시물",
|
||||
"menu.show_all_entries": "모든 게시물 표시",
|
||||
"menu.show_only_starred_entries": "즐겨찾기만 표시",
|
||||
"menu.show_only_unread_entries": "읽지 않은 게시물만 표시",
|
||||
"menu.starred": "즐겨찾기",
|
||||
"menu.title": "메뉴",
|
||||
"menu.unread": "읽지 않음",
|
||||
"menu.users": "사용자 목록",
|
||||
"page.about.authors_label": "작성자:",
|
||||
"page.about.authors_value": "Frédéric Guillot 및 기여자",
|
||||
"page.about.build_date": "빌드 일시:",
|
||||
"page.about.credits": "저작권 표시",
|
||||
"page.about.db_usage": "데이터베이스 크기:",
|
||||
"page.about.git_commit": "Git 커밋:",
|
||||
"page.about.global_config_options": "전역 설정 옵션",
|
||||
"page.about.go_version": "Go 버전:",
|
||||
"page.about.license": "라이선스:",
|
||||
"page.about.postgres_version": "Postgres 버전:",
|
||||
"page.about.title": "소프트웨어 정보",
|
||||
"page.about.version": "버전:",
|
||||
"page.add_feed.choose_feed": "피드 선택",
|
||||
"page.add_feed.label.url": "피드 URL",
|
||||
"page.add_feed.legend.advanced_options": "고급 설정",
|
||||
"page.add_feed.no_category": "카테고리가 없습니다. 카테고리가 최소 1개 필요합니다.",
|
||||
"page.add_feed.submit": "피드 탐색 및 추가",
|
||||
"page.add_feed.title": "새 피드",
|
||||
"page.api_keys.never_used": "사용된 적 없음",
|
||||
"page.api_keys.table.actions": "액션",
|
||||
"page.api_keys.table.created_at": "생성일",
|
||||
"page.api_keys.table.description": "설명",
|
||||
"page.api_keys.table.last_used_at": "마지막 사용",
|
||||
"page.api_keys.table.token": "토큰",
|
||||
"page.api_keys.title": "API 키",
|
||||
"page.categories.entries": "게시물 목록",
|
||||
"page.categories.feed_count": [
|
||||
"피드가 %d개 있습니다."
|
||||
],
|
||||
"page.categories.feeds": "피드 목록",
|
||||
"page.categories.no_feed": "피드가 없습니다.",
|
||||
"page.categories.title": "카테고리",
|
||||
"page.categories_count": [
|
||||
"카테고리 %d개"
|
||||
],
|
||||
"page.category_label": "카테고리: %s",
|
||||
"page.edit_category.title": "카테고리 편집: %s",
|
||||
"page.edit_feed.etag_header": "ETag 헤더:",
|
||||
"page.edit_feed.last_check": "마지막 확인:",
|
||||
"page.edit_feed.last_modified_header": "Last-Modified 헤더:",
|
||||
"page.edit_feed.last_parsing_error": "최근 파싱 오류",
|
||||
"page.edit_feed.no_header": "없음",
|
||||
"page.edit_feed.title": "피드 편집: %s",
|
||||
"page.edit_user.title": "사용자 편집: %s",
|
||||
"page.entry.attachments": "첨부 파일",
|
||||
"page.feeds.error_count": [
|
||||
"오류 %d개"
|
||||
],
|
||||
"page.feeds.last_check": "마지막 확인:",
|
||||
"page.feeds.next_check": "다음 확인:",
|
||||
"page.feeds.read_counter": "읽은 게시물 수",
|
||||
"page.feeds.title": "피드 목록",
|
||||
"page.footer.elevator": "페이지 맨 위로 올라가기",
|
||||
"page.history.title": "기록",
|
||||
"page.import.title": "가져오기",
|
||||
"page.integration.bookmarklet": "북마크릿",
|
||||
"page.integration.bookmarklet.help": "이 특별한 링크를 사용하면 브라우저에서 직접 웹사이트의 피드를 구독할 수 있습니다.",
|
||||
"page.integration.bookmarklet.instructions": "이 링크를 브라우저 북마크로 드래그하세요.",
|
||||
"page.integration.bookmarklet.name": "Miniflux에 추가",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API 엔드포인트",
|
||||
"page.integration.miniflux_api_password": "비밀번호",
|
||||
"page.integration.miniflux_api_password_value": "계정 비밀번호",
|
||||
"page.integration.miniflux_api_username": "사용자명",
|
||||
"page.integrations.title": "연동",
|
||||
"page.keyboard_shortcuts.close_modal": "모달 대화상자 닫기",
|
||||
"page.keyboard_shortcuts.download_content": "원본 내용 다운로드",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "가장 아래 게시물로 이동",
|
||||
"page.keyboard_shortcuts.go_to_categories": "카테고리",
|
||||
"page.keyboard_shortcuts.go_to_feed": "피드",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "피드 목록",
|
||||
"page.keyboard_shortcuts.go_to_history": "기록",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "다음 게시물",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "다음 페이지",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "이전 게시물",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "이전 페이지",
|
||||
"page.keyboard_shortcuts.go_to_search": "검색 폼으로 이동",
|
||||
"page.keyboard_shortcuts.go_to_settings": "설정",
|
||||
"page.keyboard_shortcuts.go_to_starred": "즐겨찾기",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "맨 위 게시물로 이동",
|
||||
"page.keyboard_shortcuts.go_to_unread": "읽지 않음",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "현재 페이지의 게시물을 모두 읽음으로 표시",
|
||||
"page.keyboard_shortcuts.open_comments": "댓글 링크 열기",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "현재 탭에서 댓글 링크 열기",
|
||||
"page.keyboard_shortcuts.open_item": "선택한 게시물 열기",
|
||||
"page.keyboard_shortcuts.open_original": "원본 링크 열기",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "현재 탭에서 원본 링크 열기",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
|
||||
"page.keyboard_shortcuts.remove_feed": "이 피드 삭제",
|
||||
"page.keyboard_shortcuts.save_article": "게시물 저장",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "게시물이 상단에 오도록 스크롤",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "키보드 단축키 표시",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "작업",
|
||||
"page.keyboard_shortcuts.subtitle.items": "게시물 간 이동",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "페이지 간 이동",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "섹션 이동",
|
||||
"page.keyboard_shortcuts.title": "키보드 단축키",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "즐겨찾기 표시/해제",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "첨부 파일 열기/닫기",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "읽음/읽지 않음 전환 후 다음 게시물로 이동",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "읽음/읽지 않음 전환 후 이전 게시물로 이동",
|
||||
"page.login.google_signin": "Google 계정으로 로그인",
|
||||
"page.login.oidc_signin": "%s 계정으로 로그인",
|
||||
"page.login.title": "로그인",
|
||||
"page.login.webauthn_login": "패스키로 로그인",
|
||||
"page.login.webauthn_login.error": "패스키로 로그인할 수 없음",
|
||||
"page.new_api_key.title": "새 API 키",
|
||||
"page.new_category.title": "새 카테고리",
|
||||
"page.new_user.title": "새 사용자",
|
||||
"page.offline.message": "오프라인입니다",
|
||||
"page.offline.refresh_page": "페이지를 새로 고쳐 보세요",
|
||||
"page.offline.title": "오프라인 모드",
|
||||
"page.read_entry_count": [
|
||||
"읽은 게시물 %d개"
|
||||
],
|
||||
"page.search.title": "검색 결과",
|
||||
"page.sessions.table.actions": "작업",
|
||||
"page.sessions.table.current_session": "현재 세션",
|
||||
"page.sessions.table.date": "날짜",
|
||||
"page.sessions.table.ip": "IP 주소",
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "세션",
|
||||
"page.settings.link_google_account": "Google 계정과 연동",
|
||||
"page.settings.link_oidc_account": "%s 계정과 연동",
|
||||
"page.settings.title": "설정",
|
||||
"page.settings.unlink_google_account": "Google 계정과 연동 해제",
|
||||
"page.settings.unlink_oidc_account": "%s 계정과 연동 해제",
|
||||
"page.settings.webauthn.actions": "작업",
|
||||
"page.settings.webauthn.added_on": "추가일",
|
||||
"page.settings.webauthn.delete": [
|
||||
"패스키 %d개 삭제"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "마지막 사용일",
|
||||
"page.settings.webauthn.passkey_name": "패스키 이름",
|
||||
"page.settings.webauthn.passkeys": "패스키 인증",
|
||||
"page.settings.webauthn.register": "패스키 등록",
|
||||
"page.settings.webauthn.register.error": "패스키를 등록할 수 없습니다",
|
||||
"page.shared_entries.title": "공유 게시물",
|
||||
"page.shared_entries_count": [
|
||||
"공유 게시물 %d개"
|
||||
],
|
||||
"page.starred.title": "즐겨찾기",
|
||||
"page.starred_entry_count": [
|
||||
"즐겨찾기 표시된 게시물 %d개"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"총 게시물 %d개"
|
||||
],
|
||||
"page.unread.title": "읽지 않음",
|
||||
"page.unread_entry_count": [
|
||||
"읽지 않은 게시물 %d개"
|
||||
],
|
||||
"page.users.actions": "작업",
|
||||
"page.users.admin.no": "아니오",
|
||||
"page.users.admin.yes": "예",
|
||||
"page.users.is_admin": "관리자",
|
||||
"page.users.last_login": "마지막 로그인",
|
||||
"page.users.never_logged": "로그인 기록 없음",
|
||||
"page.users.title": "사용자 목록",
|
||||
"page.users.username": "사용자명",
|
||||
"page.webauthn_rename.title": "패스키 이름 변경",
|
||||
"pagination.first": "처음",
|
||||
"pagination.last": "마지막",
|
||||
"pagination.next": "다음",
|
||||
"pagination.previous": "이전",
|
||||
"search.label": "검색",
|
||||
"search.placeholder": "… 검색",
|
||||
"search.submit": "검색",
|
||||
"skip_to_content": "콘텐츠로 건너뛰기",
|
||||
"time_elapsed.days": [
|
||||
"%d일 전"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d시간 전"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"%d분 전"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d개월 전"
|
||||
],
|
||||
"time_elapsed.not_yet": "미래",
|
||||
"time_elapsed.now": "지금",
|
||||
"time_elapsed.weeks": [
|
||||
"%d주 전"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d년 전"
|
||||
],
|
||||
"time_elapsed.yesterday": "어제",
|
||||
"tooltip.keyboard_shortcuts": "키보드 단축키: %s",
|
||||
"tooltip.logged_user": "%s로 로그인 중"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -66,8 +66,6 @@ type UserModificationRequest struct {
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
@@ -134,14 +132,6 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.ExternalFontHosts = *u.ExternalFontHosts
|
||||
}
|
||||
|
||||
if u.GoogleID != nil {
|
||||
user.GoogleID = *u.GoogleID
|
||||
}
|
||||
|
||||
if u.OpenIDConnectID != nil {
|
||||
user.OpenIDConnectID = *u.OpenIDConnectID
|
||||
}
|
||||
|
||||
if u.EntriesPerPage != nil {
|
||||
user.EntriesPerPage = *u.EntriesPerPage
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -97,7 +89,7 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
|
||||
}
|
||||
|
||||
func (a atomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
|
||||
var links []*AtomLink
|
||||
links := make([]*AtomLink, 0, len(a))
|
||||
|
||||
for _, link := range a {
|
||||
if strings.EqualFold(link.Rel, relation) {
|
||||
@@ -134,22 +126,45 @@ type atomCategory struct {
|
||||
Label string `xml:"label,attr"`
|
||||
}
|
||||
|
||||
type atomCategories []atomCategory
|
||||
|
||||
func (ac atomCategories) CategoryNames() []string {
|
||||
var categories []string
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,8 @@ var replacer = strings.NewReplacer(
|
||||
"Thurs,", "Thu,",
|
||||
"Thur,", "Thu,",
|
||||
)
|
||||
var losAngelesLocation, _ = time.LoadLocation("America/Los_Angeles")
|
||||
var newYorkLocation, _ = time.LoadLocation("America/New_York")
|
||||
|
||||
// Parse parses a given date string using a large
|
||||
// list of commonly found feed date formats.
|
||||
@@ -352,9 +354,9 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
|
||||
|
||||
// Workaround for dates that don't use GMT.
|
||||
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
|
||||
loc, _ = time.LoadLocation("America/Los_Angeles")
|
||||
loc = losAngelesLocation
|
||||
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
|
||||
loc, _ = time.LoadLocation("America/New_York")
|
||||
loc = newYorkLocation
|
||||
}
|
||||
|
||||
return time.ParseInLocation(layout, ds, loc)
|
||||
|
||||
@@ -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,38 +57,41 @@ 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} {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
entry.Title = sanitizer.TruncateHTML(value, 100)
|
||||
break
|
||||
if value = sanitizer.TruncateHTML(value, 100); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Title = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,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 []string
|
||||
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} {
|
||||
@@ -182,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()
|
||||
|
||||
@@ -22,7 +22,7 @@ type rdfAdapter struct {
|
||||
|
||||
func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed := &model.Feed{
|
||||
Title: stripTags(r.rdf.Channel.Title),
|
||||
Title: sanitizer.StripTags(r.rdf.Channel.Title),
|
||||
FeedURL: strings.TrimSpace(baseURL),
|
||||
SiteURL: strings.TrimSpace(r.rdf.Channel.Link),
|
||||
Description: strings.TrimSpace(r.rdf.Channel.Description),
|
||||
@@ -95,9 +95,9 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
// Populate the entry author.
|
||||
switch {
|
||||
case item.DublinCoreCreator != "":
|
||||
entry.Author = stripTags(item.DublinCoreCreator)
|
||||
entry.Author = sanitizer.StripTags(item.DublinCoreCreator)
|
||||
case r.rdf.Channel.DublinCoreCreator != "":
|
||||
entry.Author = stripTags(r.rdf.Channel.DublinCoreCreator)
|
||||
entry.Author = sanitizer.StripTags(r.rdf.Channel.DublinCoreCreator)
|
||||
}
|
||||
|
||||
feed.Entries = append(feed.Entries, entry)
|
||||
@@ -105,7 +105,3 @@ func (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
func stripTags(value string) string {
|
||||
return strings.TrimSpace(sanitizer.StripTags(value))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package readingtime
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -15,13 +14,15 @@ import (
|
||||
|
||||
// EstimateReadingTime returns the estimated reading time of an article in minute.
|
||||
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
|
||||
sanitizedContent := sanitizer.StripTags(content)
|
||||
truncationPoint := min(len(sanitizedContent), 50)
|
||||
const truncationPoint = 100
|
||||
|
||||
if isCJK(sanitizedContent[:truncationPoint]) {
|
||||
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
|
||||
sanitizedContent := sanitizer.StripTags(content)
|
||||
|
||||
if isCJK(sanitizedContent, truncationPoint) {
|
||||
return (utf8.RuneCountInString(sanitizedContent) + cjkReadingSpeed - 1) / cjkReadingSpeed
|
||||
}
|
||||
return int(math.Ceil(float64(countWords(sanitizedContent)) / float64(defaultReadingSpeed)))
|
||||
|
||||
return (countWords(sanitizedContent) + defaultReadingSpeed - 1) / defaultReadingSpeed
|
||||
}
|
||||
|
||||
func countWords(s string) int {
|
||||
@@ -32,20 +33,26 @@ func countWords(s string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func isCJK(text string) bool {
|
||||
totalCJK := 0
|
||||
func isCJK(text string, limit int) bool {
|
||||
var letters, totalCJK int
|
||||
for _, r := range text {
|
||||
// Numbers and control characters often used in CJK too.
|
||||
// Counting them makes detection less reliable.
|
||||
if !unicode.In(r, unicode.Letter) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range text[:min(len(text), 50)] {
|
||||
if unicode.Is(unicode.Han, r) ||
|
||||
unicode.Is(unicode.Hangul, r) ||
|
||||
unicode.Is(unicode.Hiragana, r) ||
|
||||
unicode.Is(unicode.Katakana, r) ||
|
||||
unicode.Is(unicode.Yi, r) ||
|
||||
unicode.Is(unicode.Bopomofo, r) {
|
||||
if letters++; letters == limit {
|
||||
break
|
||||
}
|
||||
|
||||
if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana, unicode.Yi, unicode.Bopomofo) {
|
||||
totalCJK++
|
||||
}
|
||||
}
|
||||
|
||||
// if at least 50% of the text is CJK, odds are that the text is in CJK.
|
||||
return totalCJK > len(text)/50
|
||||
// If at least half of the letters is CJK, odds are that the text is CJK.
|
||||
midpoint := letters / 2
|
||||
|
||||
return totalCJK > midpoint
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package readingtime
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var samples = map[string]string{
|
||||
"shortenglish": `This is a short paragraph in english, less than 250 chars.`,
|
||||
@@ -79,6 +82,20 @@ func TestEstimateReadingTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyEstimateReadingTime(t *testing.T) {
|
||||
got := EstimateReadingTime("", 200, 500)
|
||||
if got != 0 {
|
||||
t.Errorf(`Wrong reading time, got %d instead of %d`, got, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedEstimateReadingTime(t *testing.T) {
|
||||
got := EstimateReadingTime(strings.Repeat("word ", 200), 200, 500)
|
||||
if got != 1 {
|
||||
t.Errorf(`Wrong reading time, got %d instead of %d`, got, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEstimateReadingTime(b *testing.B) {
|
||||
for b.Loop() {
|
||||
for _, sample := range samples {
|
||||
@@ -97,3 +114,68 @@ func TestCountWordsZeroAllocs(t *testing.T) {
|
||||
t.Errorf("countWords allocated %v times, expected 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isCJK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
limit int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "latin short",
|
||||
input: "Lorem ipsum dolor sit amet",
|
||||
limit: 100,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "latin long",
|
||||
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce fermentum id sem sed commodo. Ut eget mauris eu lectus mollis aliquam.",
|
||||
limit: 100,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
input: "Съешь ещё этих мягких французских булок, да выпей же чаю",
|
||||
limit: 50,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "japanese",
|
||||
input: "政盤が力和文ぱのら仕2以第ミ討漢年レ毎開スヤ間度を時深読じどが画気握てぐゆぶ留海え転5航調ヤ運旬クヌロレ探憲御裕こぎびさ。能ど加開ゆぶ軍43地伐2施ヤ実58共験それ合氾み歳熊つごゅま手柴りらし認整ラカ死感は界後えい警立ゃがよト訪奏エシサミ携勝ヘヒコカ軽通年クタ公無せはぐ読階齢間て。",
|
||||
limit: 100,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "hangul",
|
||||
input: "대통령은 국가의 안위에 관계되는 중대한 교전상태에 있어서 국가를 보위하기 위하여 긴급한 조치가 필요하고 국회의 집회가 불가능한 때에 한하여 법률의 효력을 가지는 명령을 발할 수 있다. 국가는 사회적·경제적 방법으로 근로자의 고용의 증진과 적정임금의 보장에 노력하여야 하며. 국교는 인정되지 아니하며. 법률이 정한 국무위원의 순서로 그 권한을 대행한다.",
|
||||
limit: 100,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "mixed but mostly non-CJK",
|
||||
input: "Tofu (Japanese: 豆腐, Hepburn: Tōfu; Chinese: 豆腐; pinyin: dòufu; Korean: 두부; RR: dubu)",
|
||||
limit: 100,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "mixed but just enough hangul",
|
||||
input: "Korean: 국한문 혼용체; Hanja: 國漢文混用體",
|
||||
limit: 100,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := isCJK(test.input, test.limit)
|
||||
if got != test.expected {
|
||||
t.Errorf("isCJK(%q, %d) = %v, want %v", test.input, test.limit, got, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+149
-124
@@ -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)
|
||||
}
|
||||
@@ -177,30 +179,16 @@ func findFeedAuthor(rssChannel *rssChannel) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sanitizer.StripTags(author))
|
||||
return sanitizer.StripTags(author)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -296,26 +284,14 @@ func findEntryAuthor(rssItem *rssItem) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sanitizer.StripTags(author))
|
||||
return sanitizer.StripTags(author)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -482,9 +472,7 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
switch tagName {
|
||||
case "math":
|
||||
if attribute.Key == "xmlns" {
|
||||
if value != "http://www.w3.org/1998/Math/MathML" {
|
||||
value = "http://www.w3.org/1998/Math/MathML"
|
||||
}
|
||||
value = "http://www.w3.org/1998/Math/MathML"
|
||||
}
|
||||
case "img":
|
||||
switch attribute.Key {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,22 +14,39 @@ import (
|
||||
// StripTags removes all HTML/XML tags from the input string.
|
||||
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
|
||||
func StripTags(input string) string {
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(input))
|
||||
var buffer strings.Builder
|
||||
dst := &strings.Builder{}
|
||||
src := strings.NewReader(input)
|
||||
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
err := tokenizer.Err()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return buffer.String()
|
||||
}
|
||||
err := stripIter(src, func(text string) bool {
|
||||
dst.WriteString(text)
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ""
|
||||
return strings.TrimSpace(dst.String())
|
||||
}
|
||||
|
||||
// stripIter iterates over the input [io.Reader] and calls the yield function for each [html.TextToken].
|
||||
// Other kinds of [html.TokenType] are skipped.
|
||||
func stripIter(src io.Reader, yield func(string) bool) error {
|
||||
tokenizer := html.NewTokenizer(src)
|
||||
|
||||
for tokenizer.Next() != html.ErrorToken {
|
||||
token := tokenizer.Token()
|
||||
if token.Type != html.TextToken {
|
||||
continue
|
||||
}
|
||||
|
||||
token := tokenizer.Token()
|
||||
if token.Type == html.TextToken {
|
||||
buffer.WriteString(token.Data)
|
||||
if !yield(token.Data) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := tokenizer.Err(); !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "testing"
|
||||
|
||||
func TestStripTags(t *testing.T) {
|
||||
input := `This <a href="/test.html">link is relative</a> and <strong>this</strong> image: <img src="../folder/image.png"/>`
|
||||
expected := `This link is relative and this image: `
|
||||
expected := `This link is relative and this image:`
|
||||
output := StripTags(input)
|
||||
|
||||
if expected != output {
|
||||
|
||||
@@ -3,19 +3,86 @@
|
||||
|
||||
package sanitizer
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TruncateHTML(input string, max int) string {
|
||||
text := StripTags(input)
|
||||
// TruncateHTML returns cleaned up and shortened version of input.
|
||||
// - HTML tags are removed
|
||||
// - Consecutive whitespace characters replaced with single SPACE (0x20) character
|
||||
// - If input has more runes than limit, it's truncated
|
||||
func TruncateHTML(input string, limit int) string {
|
||||
dst := &strings.Builder{}
|
||||
src := strings.NewReader(input)
|
||||
|
||||
// Collapse multiple spaces into a single space
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
words := 0
|
||||
count := 0
|
||||
needspace := false
|
||||
|
||||
// Convert to runes to be safe with unicode
|
||||
runes := []rune(text)
|
||||
if len(runes) > max {
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
err := stripIter(src, func(token string) bool {
|
||||
// Skip leading space.
|
||||
if words > 0 {
|
||||
// Add a space between tokens if there's one before HTML tag.
|
||||
r, _ := utf8.DecodeRuneInString(token)
|
||||
needspace = needspace || unicode.IsSpace(r)
|
||||
}
|
||||
|
||||
for word := range strings.FieldsSeq(token) {
|
||||
if needspace {
|
||||
if count += 1; count > limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Compute how much of the word we can use later.
|
||||
wordlen := 0
|
||||
for wordlen < len(word) {
|
||||
if count += 1; count > limit {
|
||||
break
|
||||
}
|
||||
|
||||
r, w := utf8.DecodeRuneInString(word[wordlen:])
|
||||
if r == utf8.RuneError {
|
||||
wordlen += 1
|
||||
continue
|
||||
}
|
||||
|
||||
wordlen += w
|
||||
}
|
||||
|
||||
// This is the only place where space being placed.
|
||||
// That way any sequence of space characters ends up as a singular SPACE (0x20) character.
|
||||
//
|
||||
// wordlen > 0 skips spaces if no printable characters left.
|
||||
if needspace && wordlen > 0 {
|
||||
dst.WriteByte(' ')
|
||||
}
|
||||
|
||||
dst.WriteString(word[:wordlen])
|
||||
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
|
||||
needspace = true // To insert spaces in-between words in a token.
|
||||
words++
|
||||
}
|
||||
|
||||
// Add a space between tokens if there's one after HTML tag.
|
||||
r, _ := utf8.DecodeLastRuneInString(token)
|
||||
needspace = unicode.IsSpace(r) && words > 0
|
||||
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return text
|
||||
if count > limit {
|
||||
dst.WriteRune('…')
|
||||
}
|
||||
|
||||
return dst.String()
|
||||
}
|
||||
|
||||
@@ -3,73 +3,49 @@
|
||||
|
||||
package sanitizer
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateHTMWithTextLowerThanLimitL(t *testing.T) {
|
||||
input := `This is a <strong>bug 🐛</strong>.`
|
||||
expected := `This is a bug 🐛.`
|
||||
output := TruncateHTML(input, 50)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithTextAboveLimit(t *testing.T) {
|
||||
input := `This is <strong>HTML</strong>.`
|
||||
expected := `This…`
|
||||
output := TruncateHTML(input, 4)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithUnicodeTextAboveLimit(t *testing.T) {
|
||||
input := `This is a <strong>bike 🚲</strong>.`
|
||||
expected := `This…`
|
||||
output := TruncateHTML(input, 4)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithMultilineTextAboveLimit(t *testing.T) {
|
||||
input := `
|
||||
This is a <strong>bike
|
||||
🚲</strong>.
|
||||
|
||||
`
|
||||
expected := `This is a bike…`
|
||||
output := TruncateHTML(input, 15)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithMultilineTextLowerThanLimit(t *testing.T) {
|
||||
input := `
|
||||
This is a <strong>bike
|
||||
🚲</strong>.
|
||||
|
||||
`
|
||||
expected := `This is a bike 🚲.`
|
||||
output := TruncateHTML(input, 20)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
|
||||
func TestTruncateHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "text lower than limit",
|
||||
input: "This is a <strong>bug 🐛</strong>.",
|
||||
maxLen: 50,
|
||||
expected: "This is a bug 🐛.",
|
||||
},
|
||||
{
|
||||
name: "text above limit",
|
||||
input: "This is <strong>HTML</strong>.",
|
||||
maxLen: 4,
|
||||
expected: "This…",
|
||||
},
|
||||
{
|
||||
name: "unicode text above limit",
|
||||
input: "This is a <strong>bike 🚲</strong>.",
|
||||
maxLen: 4,
|
||||
expected: "This…",
|
||||
},
|
||||
{
|
||||
name: "multiline text above limit",
|
||||
input: "\n\t\tThis is a <strong>bike\n\t\t🚲</strong>.\n\n\t",
|
||||
maxLen: 15,
|
||||
expected: "This is a bike…",
|
||||
},
|
||||
{
|
||||
name: "multiline text lower than limit",
|
||||
input: "\n\t\tThis is a <strong>bike\n 🚲</strong>.\n\n\t",
|
||||
maxLen: 20,
|
||||
expected: "This is a bike 🚲.",
|
||||
},
|
||||
{
|
||||
name: "multiple spaces",
|
||||
input: "hello world test",
|
||||
@@ -100,6 +76,72 @@ func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
|
||||
maxLen: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "just enough characters",
|
||||
input: "Hello",
|
||||
maxLen: 5,
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "just enough unicode characters",
|
||||
input: "Привет",
|
||||
maxLen: 6,
|
||||
expected: "Привет",
|
||||
},
|
||||
{
|
||||
name: "spaces around tag",
|
||||
input: "hello <br/> world",
|
||||
maxLen: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "leading spaces",
|
||||
input: " hello world",
|
||||
maxLen: 5,
|
||||
expected: "hello…",
|
||||
},
|
||||
{
|
||||
name: "text above limit with space at the end",
|
||||
input: "hello world",
|
||||
maxLen: 6,
|
||||
expected: "hello…",
|
||||
},
|
||||
{
|
||||
name: "leading space before tag",
|
||||
input: " <a>hello</a>",
|
||||
maxLen: 15,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "space-only tokens in between tags",
|
||||
input: "hello <br/>\t<a> </a>world",
|
||||
maxLen: 15,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "truncate mid-word",
|
||||
input: "hello world",
|
||||
maxLen: 8,
|
||||
expected: "hello wo…",
|
||||
},
|
||||
{
|
||||
name: "truncate mid-word with unicode",
|
||||
input: "Съешь ещё этих мягких французских булок, да выпей же чаю",
|
||||
maxLen: 25,
|
||||
expected: "Съешь ещё этих мягких фра…",
|
||||
},
|
||||
{
|
||||
name: "negative limit",
|
||||
input: "whatever",
|
||||
maxLen: -10,
|
||||
expected: "…",
|
||||
},
|
||||
{
|
||||
name: "zero limit",
|
||||
input: "whatever",
|
||||
maxLen: 0,
|
||||
expected: "…",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -112,3 +154,45 @@ func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTruncateHTML(b *testing.B) {
|
||||
benches := []struct {
|
||||
filename string
|
||||
limit int
|
||||
}{
|
||||
{
|
||||
filename: "miniflux_github.html",
|
||||
limit: 100,
|
||||
},
|
||||
{
|
||||
filename: "miniflux_github.html",
|
||||
limit: 10_000,
|
||||
},
|
||||
{
|
||||
filename: "miniflux_wikipedia.html",
|
||||
limit: 100,
|
||||
},
|
||||
{
|
||||
filename: "miniflux_wikipedia.html",
|
||||
limit: 100_000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, f := range benches {
|
||||
data, err := os.ReadFile("testdata/" + f.filename)
|
||||
if err != nil {
|
||||
b.Fatalf(`Unable to read file %q: %v`, f.filename, err)
|
||||
}
|
||||
|
||||
b.Run(f.filename+"_"+strconv.Itoa(f.limit), func(b *testing.B) {
|
||||
var junk string
|
||||
|
||||
str := string(data)
|
||||
for b.Loop() {
|
||||
junk = TruncateHTML(str, 100)
|
||||
}
|
||||
|
||||
_ = junk
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,62 +125,77 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
|
||||
}
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL string, doc *goquery.Document) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
queries := map[string]string{
|
||||
"link[type='application/rss+xml']": parser.FormatRSS,
|
||||
"link[type='application/atom+xml']": parser.FormatAtom,
|
||||
"link[type='application/feed+json']": parser.FormatJSON,
|
||||
|
||||
// Ignore JSON feed URLs that contain "/wp-json/" to avoid confusion
|
||||
// with WordPress REST API endpoints.
|
||||
"link[type='application/json']:not([href*='/wp-json/'])": parser.FormatJSON,
|
||||
}
|
||||
|
||||
var subscriptions Subscriptions
|
||||
subscriptionURLs := make(map[string]bool)
|
||||
for feedQuerySelector, feedFormat := range queries {
|
||||
doc.Find(feedQuerySelector).Each(func(i int, s *goquery.Selection) {
|
||||
subscription := new(subscription)
|
||||
subscription.Type = feedFormat
|
||||
// There are 4 possible feed formats
|
||||
subscriptionURLs := make(map[string]bool, 4)
|
||||
|
||||
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
|
||||
var err error
|
||||
subscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return // without an url, there can be no subscription.
|
||||
// Single DOM walk over every <link> with a type attribute, then dispatch on
|
||||
// the MIME type. This is better than doing a separate goquery.Find pass per
|
||||
// type.
|
||||
doc.Find("link[type]").Each(func(_ int, s *goquery.Selection) {
|
||||
typeAttr, _ := s.Attr("type")
|
||||
var feedFormat string
|
||||
switch typeAttr {
|
||||
case "application/rss+xml":
|
||||
feedFormat = parser.FormatRSS
|
||||
case "application/atom+xml":
|
||||
feedFormat = parser.FormatAtom
|
||||
case "application/feed+json":
|
||||
feedFormat = parser.FormatJSON
|
||||
case "application/json":
|
||||
// Ignore JSON feed URLs that contain "/wp-json/" to avoid confusion
|
||||
// with WordPress REST API endpoints.
|
||||
if href, _ := s.Attr("href"); strings.Contains(href, "/wp-json/") {
|
||||
return
|
||||
}
|
||||
feedFormat = parser.FormatJSON
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if title, exists := s.Attr("title"); exists {
|
||||
subscription.Title = title
|
||||
}
|
||||
feedURL, _ := s.Attr("href")
|
||||
if feedURL == "" {
|
||||
return // without an url, there can be no subscription.
|
||||
}
|
||||
|
||||
if subscription.Title == "" {
|
||||
subscription.Title = subscription.URL
|
||||
}
|
||||
absoluteURL, err := urllib.ResolveToAbsoluteURL(websiteURL, feedURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !subscriptionURLs[subscription.URL] {
|
||||
subscriptionURLs[subscription.URL] = true
|
||||
subscriptions = append(subscriptions, subscription)
|
||||
}
|
||||
if subscriptionURLs[absoluteURL] {
|
||||
return
|
||||
}
|
||||
subscriptionURLs[absoluteURL] = true
|
||||
|
||||
title, _ := s.Attr("title")
|
||||
if title == "" {
|
||||
title = absoluteURL
|
||||
}
|
||||
|
||||
subscriptions = append(subscriptions, &subscription{
|
||||
Type: feedFormat,
|
||||
Title: title,
|
||||
URL: absoluteURL,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
knownURLs := map[string]string{
|
||||
"atom.xml": parser.FormatAtom,
|
||||
"feed.atom": parser.FormatAtom,
|
||||
"feed.xml": parser.FormatAtom,
|
||||
"feed/": parser.FormatAtom,
|
||||
"index.rss": parser.FormatRSS,
|
||||
"index.xml": parser.FormatRSS,
|
||||
"rss.xml": parser.FormatRSS,
|
||||
"rss/": parser.FormatRSS,
|
||||
"rss/feed.xml": parser.FormatRSS,
|
||||
knownURLs := [...]struct {
|
||||
path, format string
|
||||
}{
|
||||
{"atom.xml", parser.FormatAtom},
|
||||
{"feed.atom", parser.FormatAtom},
|
||||
{"feed.xml", parser.FormatAtom},
|
||||
{"feed/", parser.FormatAtom},
|
||||
{"index.rss", parser.FormatRSS},
|
||||
{"index.xml", parser.FormatRSS},
|
||||
{"rss.xml", parser.FormatRSS},
|
||||
{"rss/", parser.FormatRSS},
|
||||
{"rss/feed.xml", parser.FormatRSS},
|
||||
}
|
||||
|
||||
websiteURLRoot := urllib.RootURL(websiteURL)
|
||||
@@ -197,8 +212,8 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
|
||||
|
||||
var subscriptions Subscriptions
|
||||
for _, baseURL := range baseURLs {
|
||||
for knownURL, kind := range knownURLs {
|
||||
fullURL, err := urllib.ResolveToAbsoluteURL(baseURL, knownURL)
|
||||
for _, known := range knownURLs {
|
||||
fullURL, err := urllib.ResolveToAbsoluteURL(baseURL, known.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -206,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()
|
||||
|
||||
@@ -227,7 +245,7 @@ func (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL strin
|
||||
}
|
||||
|
||||
subscriptions = append(subscriptions, &subscription{
|
||||
Type: kind,
|
||||
Type: known.format,
|
||||
Title: fullURL,
|
||||
URL: fullURL,
|
||||
})
|
||||
@@ -319,12 +337,16 @@ func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Su
|
||||
// findCanonicalURL extracts the canonical URL from the HTML <link rel="canonical"> tag.
|
||||
// Returns the canonical URL if found, otherwise returns the effective URL.
|
||||
func (f *subscriptionFinder) findCanonicalURL(effectiveURL, baseURL string, doc *goquery.Document) string {
|
||||
canonicalHref, exists := doc.Find("head link[rel='canonical' i]").First().Attr("href")
|
||||
if !exists || strings.TrimSpace(canonicalHref) == "" {
|
||||
canonicalHref, exists := doc.FindMatcher(goquery.Single("head link[rel='canonical' i]")).Attr("href")
|
||||
if !exists {
|
||||
return effectiveURL
|
||||
}
|
||||
canonicalHref = strings.TrimSpace(canonicalHref)
|
||||
if canonicalHref == "" {
|
||||
return effectiveURL
|
||||
}
|
||||
|
||||
canonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, strings.TrimSpace(canonicalHref))
|
||||
canonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, canonicalHref)
|
||||
if err != nil {
|
||||
return effectiveURL
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,11 +47,6 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// NewEntryQueryBuilder returns a new EntryQueryBuilder
|
||||
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
|
||||
return NewEntryQueryBuilder(s, userID)
|
||||
}
|
||||
|
||||
// UpdateEntryTitleAndContent updates entry title and content.
|
||||
func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
|
||||
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
|
||||
@@ -456,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
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// entryPaginationBuilder is a builder for entry prev/next queries.
|
||||
type entryPaginationBuilder struct {
|
||||
store *Storage
|
||||
db *sql.DB
|
||||
conditions []string
|
||||
args []any
|
||||
entryID int64
|
||||
@@ -24,77 +25,91 @@ type entryPaginationBuilder struct {
|
||||
}
|
||||
|
||||
// WithSearchQuery adds full-text search query to the condition.
|
||||
func (e *entryPaginationBuilder) WithSearchQuery(query string) {
|
||||
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)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithStarred adds starred to the condition.
|
||||
func (e *entryPaginationBuilder) WithStarred() {
|
||||
func (e *entryPaginationBuilder) WithStarred() *entryPaginationBuilder {
|
||||
e.conditions = append(e.conditions, "e.starred is true")
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithFeedID adds feed_id to the condition.
|
||||
func (e *entryPaginationBuilder) WithFeedID(feedID int64) {
|
||||
func (e *entryPaginationBuilder) WithFeedID(feedID int64) *entryPaginationBuilder {
|
||||
if feedID != 0 {
|
||||
e.conditions = append(e.conditions, "e.feed_id = $"+strconv.Itoa(len(e.args)+1))
|
||||
e.args = append(e.args, feedID)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCategoryID adds category_id to the condition.
|
||||
func (e *entryPaginationBuilder) WithCategoryID(categoryID int64) {
|
||||
func (e *entryPaginationBuilder) WithCategoryID(categoryID int64) *entryPaginationBuilder {
|
||||
if categoryID != 0 {
|
||||
e.conditions = append(e.conditions, "f.category_id = $"+strconv.Itoa(len(e.args)+1))
|
||||
e.args = append(e.args, categoryID)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithStatus adds status to the condition.
|
||||
func (e *entryPaginationBuilder) WithStatus(status string) {
|
||||
func (e *entryPaginationBuilder) WithStatus(status string) *entryPaginationBuilder {
|
||||
if status != "" {
|
||||
e.conditions = append(e.conditions, "e.status = $"+strconv.Itoa(len(e.args)+1))
|
||||
e.args = append(e.args, status)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithStatusOrEntryID adds a status condition that always includes a specific entry ID.
|
||||
func (e *entryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) {
|
||||
func (e *entryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) *entryPaginationBuilder {
|
||||
if status == "" {
|
||||
return
|
||||
return e
|
||||
}
|
||||
|
||||
if entryID == 0 {
|
||||
e.WithStatus(status)
|
||||
return
|
||||
return e
|
||||
}
|
||||
|
||||
statusArg := len(e.args) + 1
|
||||
entryArg := len(e.args) + 2
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("(e.status = $%d OR e.id = $%d)", statusArg, entryArg))
|
||||
e.args = append(e.args, status, entryID)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *entryPaginationBuilder) WithTags(tags []string) {
|
||||
func (e *entryPaginationBuilder) WithTags(tags []string) *entryPaginationBuilder {
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
|
||||
e.args = append(e.args, tag)
|
||||
}
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER(e.tags::text)::text[] @> LOWER($%d::text)::text[]", len(e.args)+1))
|
||||
e.args = append(e.args, pq.Array(tags))
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// WithGloballyVisible adds global visibility to the condition.
|
||||
func (e *entryPaginationBuilder) WithGloballyVisible() {
|
||||
func (e *entryPaginationBuilder) WithGloballyVisible() *entryPaginationBuilder {
|
||||
e.conditions = append(e.conditions, "not c.hide_globally")
|
||||
e.conditions = append(e.conditions, "not f.hide_globally")
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Entries returns previous and next entries.
|
||||
func (e *entryPaginationBuilder) Entries() (*model.Entry, *model.Entry, error) {
|
||||
tx, err := e.store.db.Begin()
|
||||
tx, err := e.db.Begin()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin transaction for entry pagination: %v", err)
|
||||
}
|
||||
@@ -186,13 +201,13 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
|
||||
}
|
||||
|
||||
// NewEntryPaginationBuilder returns a new EntryPaginationBuilder.
|
||||
func NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {
|
||||
func (s *Storage) NewEntryPaginationBuilder(userID, entryID int64, order, direction string) *entryPaginationBuilder {
|
||||
return &entryPaginationBuilder{
|
||||
store: store,
|
||||
db: s.db,
|
||||
args: []any{userID},
|
||||
conditions: []string{"e.user_id = $1"},
|
||||
entryID: entryID,
|
||||
order: order,
|
||||
order: pq.QuoteIdentifier(order),
|
||||
direction: direction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.WithSorting(
|
||||
fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001", nArgs),
|
||||
"DESC",
|
||||
|
||||
e.sortExpressions = append(e.sortExpressions,
|
||||
fmt.Sprintf("ts_rank(document_vectors, websearch_to_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001 DESC", nArgs),
|
||||
)
|
||||
}
|
||||
return e
|
||||
@@ -115,7 +115,7 @@ func (e *EntryQueryBuilder) AfterEntryID(entryID int64) *EntryQueryBuilder {
|
||||
}
|
||||
|
||||
// WithEntryIDs filter by entry IDs.
|
||||
func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
|
||||
func (e *EntryQueryBuilder) WithEntryIDs(entryIDs ...int64) *EntryQueryBuilder {
|
||||
if len(entryIDs) == 1 {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.id = $%d", len(e.args)+1))
|
||||
e.args = append(e.args, entryIDs[0])
|
||||
@@ -126,15 +126,6 @@ func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithEntryID filter by entry ID.
|
||||
func (e *EntryQueryBuilder) WithEntryID(entryID int64) *EntryQueryBuilder {
|
||||
if entryID != 0 {
|
||||
e.conditions = append(e.conditions, "e.id = $"+strconv.Itoa(len(e.args)+1))
|
||||
e.args = append(e.args, entryID)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// WithFeedID filter by feed ID.
|
||||
func (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder {
|
||||
if feedID > 0 {
|
||||
@@ -153,17 +144,8 @@ func (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder
|
||||
return e
|
||||
}
|
||||
|
||||
// WithStatus filter by entry status.
|
||||
func (e *EntryQueryBuilder) WithStatus(status string) *EntryQueryBuilder {
|
||||
if status != "" {
|
||||
e.conditions = append(e.conditions, "e.status = $"+strconv.Itoa(len(e.args)+1))
|
||||
e.args = append(e.args, status)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// WithStatuses filter by a list of entry statuses.
|
||||
func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
|
||||
func (e *EntryQueryBuilder) WithStatuses(statuses ...string) *EntryQueryBuilder {
|
||||
if len(statuses) == 1 {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("e.status = $%d", len(e.args)+1))
|
||||
e.args = append(e.args, statuses[0])
|
||||
@@ -175,12 +157,10 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
|
||||
}
|
||||
|
||||
// WithTags filter by a list of entry tags.
|
||||
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
|
||||
func (e *EntryQueryBuilder) WithTags(tags ...string) *EntryQueryBuilder {
|
||||
if len(tags) > 0 {
|
||||
for _, cat := range tags {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
|
||||
e.args = append(e.args, cat)
|
||||
}
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER(e.tags::text)::text[] @> LOWER($%d::text)::text[]", len(e.args)+1))
|
||||
e.args = append(e.args, pq.Array(tags))
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -209,7 +189,13 @@ func (e *EntryQueryBuilder) WithShareCodeNotEmpty() *EntryQueryBuilder {
|
||||
|
||||
// WithSorting add a sort expression.
|
||||
func (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBuilder {
|
||||
e.sortExpressions = append(e.sortExpressions, column+" "+direction)
|
||||
switch {
|
||||
case strings.EqualFold(direction, "ASC"):
|
||||
e.sortExpressions = append(e.sortExpressions, pq.QuoteIdentifier(column)+" ASC")
|
||||
case strings.EqualFold(direction, "DESC"):
|
||||
e.sortExpressions = append(e.sortExpressions, pq.QuoteIdentifier(column)+" DESC")
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -221,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 {
|
||||
@@ -264,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
|
||||
}
|
||||
@@ -335,15 +329,15 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
|
||||
u.timezone
|
||||
FROM
|
||||
entries e
|
||||
LEFT JOIN
|
||||
INNER JOIN
|
||||
feeds f ON f.id=e.feed_id
|
||||
LEFT JOIN
|
||||
INNER JOIN
|
||||
categories c ON c.id=f.category_id
|
||||
LEFT JOIN
|
||||
feed_icons fi ON fi.feed_id=f.id
|
||||
LEFT JOIN
|
||||
icons i ON i.id=fi.icon_id
|
||||
LEFT JOIN
|
||||
INNER JOIN
|
||||
users u ON u.id=e.user_id
|
||||
WHERE ` + e.buildCondition() + " " + e.buildSorting()
|
||||
|
||||
@@ -410,7 +404,6 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
|
||||
}
|
||||
|
||||
err := rows.Scan(dest...)
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: unable to fetch entry row: %v", err)
|
||||
}
|
||||
@@ -440,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)
|
||||
}
|
||||
@@ -477,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"
|
||||
@@ -519,17 +526,17 @@ func (e *EntryQueryBuilder) buildSorting() string {
|
||||
}
|
||||
|
||||
// NewEntryQueryBuilder returns a new EntryQueryBuilder.
|
||||
func NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder {
|
||||
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
|
||||
return &EntryQueryBuilder{
|
||||
store: store,
|
||||
store: s,
|
||||
args: []any{userID},
|
||||
conditions: []string{"e.user_id = $1"},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAnonymousQueryBuilder returns a new EntryQueryBuilder suitable for anonymous users.
|
||||
func NewAnonymousQueryBuilder(store *Storage) *EntryQueryBuilder {
|
||||
func (s *Storage) NewAnonymousQueryBuilder() *EntryQueryBuilder {
|
||||
return &EntryQueryBuilder{
|
||||
store: store,
|
||||
store: s,
|
||||
}
|
||||
}
|
||||
|
||||
+17
-20
@@ -126,9 +126,9 @@ func (s *Storage) CountAllFeedsWithErrors() (int, error) {
|
||||
|
||||
// Feeds returns all feeds that belong to the given user.
|
||||
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||
return builder.GetFeeds()
|
||||
return s.NewFeedQueryBuilder(userID).
|
||||
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection).
|
||||
GetFeeds()
|
||||
}
|
||||
|
||||
func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
|
||||
@@ -142,27 +142,26 @@ func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
|
||||
|
||||
// FeedsWithCounters returns all feeds of the given user with read and unread entry counters.
|
||||
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCounters()
|
||||
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||
return getFeedsSorted(builder)
|
||||
return getFeedsSorted(s.NewFeedQueryBuilder(userID).
|
||||
WithCounters().
|
||||
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection))
|
||||
}
|
||||
|
||||
// FetchCounters returns the per-feed read and unread entry counts for the given user.
|
||||
func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCounters()
|
||||
reads, unreads, err := builder.fetchFeedCounter()
|
||||
reads, unreads, err := s.NewFeedQueryBuilder(userID).
|
||||
WithCounters().
|
||||
fetchFeedCounter()
|
||||
|
||||
return model.FeedCounters{ReadCounters: reads, UnreadCounters: unreads}, err
|
||||
}
|
||||
|
||||
// FeedsByCategoryWithCounters returns all feeds in the given category for the given user with read and unread entry counters.
|
||||
func (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithCounters()
|
||||
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||
return getFeedsSorted(builder)
|
||||
return getFeedsSorted(s.NewFeedQueryBuilder(userID).
|
||||
WithCategoryID(categoryID).
|
||||
WithCounters().
|
||||
WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection))
|
||||
}
|
||||
|
||||
// WeeklyFeedEntryCount returns the weekly entry count for a feed.
|
||||
@@ -199,9 +198,9 @@ func (s *Storage) WeeklyFeedEntryCount(userID, feedID int64) (int, error) {
|
||||
|
||||
// FeedByID returns the feed with the given ID.
|
||||
func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithFeedID(feedID)
|
||||
feed, err := builder.GetFeed()
|
||||
feed, err := s.NewFeedQueryBuilder(userID).
|
||||
WithFeedID(feedID).
|
||||
GetFeed()
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
@@ -417,7 +416,6 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
feed.ID,
|
||||
feed.UserID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to update feed #%d (%s): %v`, feed.ID, feed.FeedURL, err)
|
||||
}
|
||||
@@ -446,7 +444,6 @@ func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
|
||||
feed.ID,
|
||||
feed.UserID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to update feed error #%d (%s): %v`, feed.ID, feed.FeedURL, err)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/timezone"
|
||||
)
|
||||
|
||||
// feedQueryBuilder builds a SQL query to fetch feeds.
|
||||
type feedQueryBuilder struct {
|
||||
store *Storage
|
||||
db *sql.DB
|
||||
args []any
|
||||
conditions []string
|
||||
sortExpressions []string
|
||||
@@ -28,9 +29,9 @@ type feedQueryBuilder struct {
|
||||
}
|
||||
|
||||
// NewFeedQueryBuilder returns a new FeedQueryBuilder.
|
||||
func NewFeedQueryBuilder(store *Storage, userID int64) *feedQueryBuilder {
|
||||
func (s *Storage) NewFeedQueryBuilder(userID int64) *feedQueryBuilder {
|
||||
return &feedQueryBuilder{
|
||||
store: store,
|
||||
db: s.db,
|
||||
args: []any{userID},
|
||||
conditions: []string{"f.user_id = $1"},
|
||||
counterArgs: []any{userID, model.EntryStatusRead, model.EntryStatusUnread},
|
||||
@@ -67,7 +68,13 @@ func (f *feedQueryBuilder) WithCounters() *feedQueryBuilder {
|
||||
|
||||
// WithSorting add a sort expression.
|
||||
func (f *feedQueryBuilder) WithSorting(column, direction string) *feedQueryBuilder {
|
||||
f.sortExpressions = append(f.sortExpressions, column+" "+direction)
|
||||
switch {
|
||||
case strings.EqualFold(direction, "ASC"):
|
||||
f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" ASC")
|
||||
case strings.EqualFold(direction, "DESC"):
|
||||
f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" DESC")
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -130,7 +137,7 @@ func (f *feedQueryBuilder) GetFeed() (*model.Feed, error) {
|
||||
|
||||
// GetFeeds returns a list of feeds that match the condition.
|
||||
func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
var query = `
|
||||
query := `
|
||||
SELECT
|
||||
f.id,
|
||||
f.feed_url,
|
||||
@@ -199,7 +206,7 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := f.store.db.Query(query, f.args...)
|
||||
rows, err := f.db.Query(query, f.args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch feeds: %w`, err)
|
||||
}
|
||||
@@ -261,7 +268,6 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
&feed.ProxyURL,
|
||||
&feed.IgnoreEntryUpdates,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err)
|
||||
}
|
||||
@@ -312,11 +318,11 @@ func (f *feedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, rea
|
||||
`
|
||||
join := ""
|
||||
if f.counterJoinFeeds {
|
||||
join = "LEFT JOIN feeds f ON f.id=e.feed_id"
|
||||
join = "INNER JOIN feeds f ON f.id=e.feed_id"
|
||||
}
|
||||
query = fmt.Sprintf(query, join, f.buildCounterCondition())
|
||||
|
||||
rows, err := f.store.db.Query(query, f.counterArgs...)
|
||||
rows, err := f.db.Query(query, f.counterArgs...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(`store: unable to fetch feed counts: %w`, err)
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
|
||||
icons.content,
|
||||
icons.external_id
|
||||
FROM icons
|
||||
LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
|
||||
LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
|
||||
INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
|
||||
INNER JOIN feeds ON feeds.id=feed_icons.feed_id
|
||||
WHERE
|
||||
feeds.user_id=$1 AND feeds.id=$2
|
||||
LIMIT 1
|
||||
@@ -175,8 +175,8 @@ func (s *Storage) Icons(userID int64) (model.Icons, error) {
|
||||
icons.content,
|
||||
icons.external_id
|
||||
FROM icons
|
||||
LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
|
||||
LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
|
||||
INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
|
||||
INNER JOIN feeds ON feeds.id=feed_icons.feed_id
|
||||
WHERE
|
||||
feeds.user_id=$1
|
||||
`
|
||||
|
||||
@@ -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
|
||||
@@ -513,7 +515,7 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
|
||||
u.open_external_links_in_new_tab
|
||||
FROM
|
||||
users u
|
||||
LEFT JOIN
|
||||
INNER JOIN
|
||||
api_keys ON api_keys.user_id=u.id
|
||||
WHERE
|
||||
api_keys.token = $1
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user