Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4d54f87a8 | |||
| 1d10e7a089 | |||
| 0e26f12426 | |||
| e4e7dc55a7 | |||
| c8c414e8fb | |||
| 82537616f5 | |||
| 308e1f966c | |||
| fbbff63f2e | |||
| c342187b7e | |||
| 5c62899df9 | |||
| a21029dfb7 | |||
| 4237f8b090 | |||
| c119273b89 | |||
| 92057dde56 | |||
| 4d84eee221 | |||
| aa509b8802 | |||
| 56eb812b29 | |||
| 69a788ab35 | |||
| 706a92e700 | |||
| 0939195966 | |||
| ff01425686 | |||
| 79d920bc1a | |||
| cf5ae57d9a | |||
| 070bc9ef3d | |||
| e7888e3d43 | |||
| 8528e5e650 | |||
| e43381d17a | |||
| 5ebdc176e7 | |||
| 0abd9e7145 | |||
| ef24215bde | |||
| 56d0b31cb6 | |||
| 5f710f916d | |||
| 65cd6cd25d | |||
| 6972be2e85 | |||
| ab8f7f9eb4 | |||
| 766d298095 | |||
| 68e2655b77 | |||
| fe37c6ba43 | |||
| 0dcefa7c62 | |||
| 5d56902c7f | |||
| 212c3e13d5 | |||
| 6dcb815c25 | |||
| d456718c05 | |||
| 4aa60ba23d | |||
| 7769fa06ef | |||
| 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,9 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
|
||||
@@ -38,22 +38,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- 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/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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,9 +17,9 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
@@ -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,9 +44,9 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+31
-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,16 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Language string `json:"language"`
|
||||
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 +176,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 +197,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 +218,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 +234,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"`
|
||||
@@ -258,6 +271,7 @@ type Entry struct {
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
Author string `json:"author"`
|
||||
ShareCode string `json:"share_code"`
|
||||
Enclosures Enclosures `json:"enclosures,omitempty"`
|
||||
@@ -320,6 +334,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
Tags []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
@@ -329,6 +344,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"`
|
||||
|
||||
@@ -7,22 +7,22 @@ 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/andybalholm/brotli v1.2.2
|
||||
github.com/coreos/go-oidc/v3 v3.20.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/prometheus/client_golang v1.24.0
|
||||
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.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/net v0.57.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.45.0
|
||||
golang.org/x/text v0.40.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
|
||||
)
|
||||
@@ -35,16 +35,14 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
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
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
@@ -19,10 +18,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=
|
||||
@@ -34,12 +33,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
@@ -50,16 +45,14 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||
@@ -80,18 +73,18 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
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 +99,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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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 +121,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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.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 +132,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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
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 +143,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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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=
|
||||
@@ -159,10 +152,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByID(iconID)
|
||||
icon, err := h.store.IconByUserAndIconID(request.UserID(r), iconID)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
+36
-21
@@ -27,6 +27,9 @@ func startDaemon(store *storage.Storage) {
|
||||
signal.Notify(stop, os.Interrupt)
|
||||
signal.Notify(stop, syscall.SIGTERM)
|
||||
|
||||
reload := make(chan os.Signal, 1)
|
||||
signal.Notify(reload, syscall.SIGHUP)
|
||||
|
||||
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
|
||||
|
||||
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
|
||||
@@ -34,8 +37,9 @@ func startDaemon(store *storage.Storage) {
|
||||
}
|
||||
|
||||
var httpServers []*http.Server
|
||||
var certReloadFn func()
|
||||
if config.Opts.HasHTTPService() {
|
||||
httpServers = server.StartWebServer(store, pool)
|
||||
httpServers, certReloadFn = server.StartWebServer(store, pool)
|
||||
}
|
||||
|
||||
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
|
||||
@@ -74,29 +78,40 @@ func startDaemon(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
<-stop
|
||||
slog.Debug("Shutting down the process")
|
||||
cancelMetrics()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
slog.Debug("Shutting down the process")
|
||||
cancelMetrics()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if len(httpServers) > 0 {
|
||||
slog.Debug("Shutting down HTTP servers...")
|
||||
for _, server := range httpServers {
|
||||
if server != nil {
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
|
||||
if len(httpServers) > 0 {
|
||||
slog.Debug("Shutting down HTTP servers...")
|
||||
for _, srv := range httpServers {
|
||||
if srv != nil {
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", srv.Addr))
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("All HTTP servers shut down.")
|
||||
} else {
|
||||
slog.Debug("No HTTP servers to shut down.")
|
||||
}
|
||||
|
||||
slog.Debug("Shutting down worker pool...")
|
||||
pool.Shutdown()
|
||||
slog.Debug("Worker pool shut down.")
|
||||
|
||||
slog.Debug("Process gracefully stopped")
|
||||
return
|
||||
|
||||
case <-reload:
|
||||
slog.Info("Received SIGHUP, reloading TLS certificates")
|
||||
if certReloadFn != nil {
|
||||
certReloadFn()
|
||||
}
|
||||
}
|
||||
slog.Debug("All HTTP servers shut down.")
|
||||
} else {
|
||||
slog.Debug("No HTTP servers to shut down.")
|
||||
}
|
||||
|
||||
slog.Debug("Shutting down worker pool...")
|
||||
pool.Shutdown()
|
||||
slog.Debug("Worker pool shut down.")
|
||||
|
||||
slog.Debug("Process gracefully stopped")
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -383,7 +384,11 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "image",
|
||||
valueType: stringListType,
|
||||
validator: func(rawValue string) error {
|
||||
return validateListChoices(strings.Split(rawValue, ","), []string{"image", "video", "audio"})
|
||||
resourceTypes := parseStringListValue(rawValue, nil)
|
||||
if len(resourceTypes) == 0 {
|
||||
return errors.New("at least one resource type is required")
|
||||
}
|
||||
return validateListChoices(resourceTypes, []string{"image", "video", "audio"})
|
||||
},
|
||||
},
|
||||
"METRICS_ALLOWED_NETWORKS": {
|
||||
@@ -528,6 +533,9 @@ func NewConfigOptions() *configOptions {
|
||||
parsedIntValue: 1,
|
||||
rawValue: "1",
|
||||
valueType: intType,
|
||||
validator: func(rawValue string) error {
|
||||
return validateGreaterOrEqualThan(rawValue, 1)
|
||||
},
|
||||
},
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": {
|
||||
parsedDuration: 24 * time.Hour,
|
||||
@@ -566,7 +574,11 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "",
|
||||
valueType: stringListType,
|
||||
validator: func(rawValue string) error {
|
||||
for ip := range strings.SplitSeq(rawValue, ",") {
|
||||
networks := parseStringListValue(rawValue, nil)
|
||||
if len(networks) == 0 {
|
||||
return errors.New("at least one CIDR notation network is required")
|
||||
}
|
||||
for _, ip := range networks {
|
||||
if _, _, err := net.ParseCIDR(ip); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1149,6 +1149,14 @@ func TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {
|
||||
if configParser.options.SchedulerEntryFrequencyFactor() != 2 {
|
||||
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=0"}); err == nil {
|
||||
t.Fatalf("Expected an error for SCHEDULER_ENTRY_FREQUENCY_FACTOR=0")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=-1"}); err == nil {
|
||||
t.Fatalf("Expected an error for SCHEDULER_ENTRY_FREQUENCY_FACTOR=-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {
|
||||
@@ -1500,9 +1508,26 @@ func TestMediaProxyResourceTypesOptionParsing(t *testing.T) {
|
||||
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image, video"}); err != nil {
|
||||
t.Fatalf("Unexpected error for value with spaces: %v", err)
|
||||
}
|
||||
|
||||
resourceTypes = configParser.options.MediaProxyResourceTypes()
|
||||
if len(resourceTypes) != 2 || resourceTypes[0] != "image" || resourceTypes[1] != "video" {
|
||||
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,video,"}); err != nil {
|
||||
t.Fatalf("Unexpected error for value with trailing comma: %v", err)
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video"}); err == nil {
|
||||
t.Fatal("Expected error due to invalid resource type")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=,"}); err == nil {
|
||||
t.Fatal("Expected error for value without any resource type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAllowedNetworksOptionParsing(t *testing.T) {
|
||||
@@ -1644,10 +1669,30 @@ func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
|
||||
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
|
||||
}
|
||||
|
||||
// Test value with spaces and trailing comma
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=192.168.0.0/16, 10.0.0.0/8,"}); err != nil {
|
||||
t.Fatalf("Unexpected error for value with spaces: %v", err)
|
||||
}
|
||||
|
||||
allowedNetworks = configParser.options.TrustedReverseProxyNetworks()
|
||||
if len(allowedNetworks) != 2 {
|
||||
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
|
||||
}
|
||||
if !slices.Contains(allowedNetworks, "192.168.0.0/16") {
|
||||
t.Errorf("Expected 192.168.0.0/16 in allowed networks")
|
||||
}
|
||||
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
|
||||
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
|
||||
}
|
||||
|
||||
// Test invalid value
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
|
||||
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=,"}); err == nil {
|
||||
t.Fatal("Expected error for value without any network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1470,10 +1479,15 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
CREATE INDEX entry_tombstones_deleted_at_idx
|
||||
ON entry_tombstones (deleted_at);
|
||||
|
||||
-- Some databases contain orphaned entries whose feed is gone,
|
||||
-- e.g. restored from a dump without foreign key enforcement.
|
||||
-- Skip them: their tombstones would be useless anyway, and the
|
||||
-- rows are deleted just below.
|
||||
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
|
||||
SELECT feed_id, hash, changed_at
|
||||
FROM entries
|
||||
WHERE status = 'removed' AND hash <> ''
|
||||
AND feed_id IN (SELECT id FROM feeds)
|
||||
ON CONFLICT (feed_id, hash) DO NOTHING;
|
||||
|
||||
DELETE FROM entries WHERE status = 'removed';
|
||||
@@ -1510,4 +1524,37 @@ 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
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN language text not null default '';
|
||||
ALTER TABLE entries ADD COLUMN language text not null default '';
|
||||
`)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -4,16 +4,23 @@
|
||||
package client // import "miniflux.app/v2/internal/http/client"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultRequestTimeout = 10 * time.Second
|
||||
|
||||
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
|
||||
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
|
||||
|
||||
@@ -68,3 +75,93 @@ func NewClientWithOptions(opts Options) *http.Client {
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
// requestBuilder builds and executes HTTP requests with the builder pattern.
|
||||
type requestBuilder struct {
|
||||
err error
|
||||
endpoint string
|
||||
method string
|
||||
body io.Reader
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
// NewRequestBuilder creates a new request builder for the given endpoint.
|
||||
func NewRequestBuilder(endpoint string) *requestBuilder {
|
||||
return &requestBuilder{
|
||||
endpoint: endpoint,
|
||||
method: http.MethodGet,
|
||||
headers: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// WithMethod sets the HTTP method.
|
||||
func (r *requestBuilder) WithMethod(method string) *requestBuilder {
|
||||
r.method = method
|
||||
return r
|
||||
}
|
||||
|
||||
// WithHeader sets a header value.
|
||||
func (r *requestBuilder) WithHeader(key, value string) *requestBuilder {
|
||||
r.headers.Set(key, value)
|
||||
return r
|
||||
}
|
||||
|
||||
// WithJSON marshals payload as JSON, sets the body and Content-Type.
|
||||
func (r *requestBuilder) WithJSON(payload any) *requestBuilder {
|
||||
requestBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
r.err = fmt.Errorf("unable to encode request body: %w", err)
|
||||
return r
|
||||
}
|
||||
|
||||
return r.WithJSONBody(requestBody)
|
||||
}
|
||||
|
||||
// WithJSONBody sets an already-marshaled JSON body and the Content-Type.
|
||||
// It is useful when the caller needs the encoded payload for another
|
||||
// purpose (e.g. computing a signature) to avoid marshaling it twice.
|
||||
func (r *requestBuilder) WithJSONBody(body []byte) *requestBuilder {
|
||||
r.body = bytes.NewReader(body)
|
||||
r.headers.Set("Content-Type", "application/json")
|
||||
return r
|
||||
}
|
||||
|
||||
// Do builds and executes the request.
|
||||
//
|
||||
// Private networks are blocked unless explicitly allowed through the
|
||||
// INTEGRATION_ALLOW_PRIVATE_NETWORKS option.
|
||||
func (r *requestBuilder) Do() (*http.Response, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
// The request is assembled lazily here rather than being stored as a
|
||||
// prebuilt *http.Request in the builder: http.NewRequest inspects the
|
||||
// body's concrete type (e.g. *bytes.Reader) to populate ContentLength and
|
||||
// GetBody. Constructing it only once the body is known yields a correct
|
||||
// Content-Length header and lets the client replay the body on redirects.
|
||||
req, err := http.NewRequest(r.method, r.endpoint, r.body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create request: %w", err)
|
||||
}
|
||||
|
||||
for key, values := range r.headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
clientOptions := Options{
|
||||
Timeout: defaultRequestTimeout,
|
||||
BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks(),
|
||||
}
|
||||
|
||||
response, err := NewClientWithOptions(clientOptions).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to send request: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
|
||||
@@ -111,3 +115,73 @@ func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilderWithJSON(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
var gotMethod, gotContentType, gotUserAgent, gotAuth, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := NewRequestBuilder(server.URL).
|
||||
WithMethod(http.MethodPost).
|
||||
WithHeader("Authorization", "Bearer secret").
|
||||
WithJSON(map[string]string{"hello": "world"}).
|
||||
Do()
|
||||
if err != nil {
|
||||
t.Fatalf("request execution failed: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Errorf("expected status %d, got %d", http.StatusCreated, response.StatusCode)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("expected method POST, got %s", gotMethod)
|
||||
}
|
||||
if gotContentType != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", gotContentType)
|
||||
}
|
||||
if want := "Miniflux/" + version.Version; gotUserAgent != want {
|
||||
t.Errorf("expected User-Agent %q, got %q", want, gotUserAgent)
|
||||
}
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Errorf("expected Authorization %q, got %q", "Bearer secret", gotAuth)
|
||||
}
|
||||
if gotBody != `{"hello":"world"}` {
|
||||
t.Errorf("expected body %q, got %q", `{"hello":"world"}`, gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilderWithInvalidEndpoint(t *testing.T) {
|
||||
_, err := NewRequestBuilder("://invalid").WithMethod(http.MethodPost).WithJSON(nil).Do()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid endpoint, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,31 +6,58 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/andybalholm/brotli/matchfinder"
|
||||
)
|
||||
|
||||
const compressionThreshold = 1024
|
||||
|
||||
// Compression writers are pooled so each request reuses their internal
|
||||
// state (brotli sliding window + hash tables, flate dictionary, etc.)
|
||||
// instead of allocating it from scratch. Reset(dst) rebinds the
|
||||
// destination without re-allocating the buffers.
|
||||
var (
|
||||
brotliWriterPool = sync.Pool{
|
||||
New: func() any {
|
||||
return brotli.NewWriterV2(io.Discard, brotli.DefaultCompression)
|
||||
},
|
||||
}
|
||||
gzipWriterPool = sync.Pool{
|
||||
New: func() any {
|
||||
return gzip.NewWriter(io.Discard)
|
||||
},
|
||||
}
|
||||
flateWriterPool = sync.Pool{
|
||||
New: func() any {
|
||||
w, _ := flate.NewWriter(io.Discard, flate.DefaultCompression)
|
||||
return w
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Builder generates HTTP responses.
|
||||
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 +68,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 +92,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 +111,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,45 +149,56 @@ 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)
|
||||
}
|
||||
|
||||
// values should be in sync with [Builder.compress] switch/case.
|
||||
var acceptEncoding = AcceptEncoding("br", "gzip", "deflate")
|
||||
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
b.headers["Vary"] = "Accept-Encoding"
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.headers.Set("Vary", "Accept-Encoding")
|
||||
|
||||
encoding := acceptEncoding.Parse(b.r.Header.Get("Accept-Encoding"))
|
||||
switch encoding {
|
||||
case "br":
|
||||
b.headers.Set("Content-Encoding", "br")
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
brotliWriter := brotliWriterPool.Get().(*matchfinder.Writer)
|
||||
brotliWriter.Reset(b.w)
|
||||
brotliWriter.Write(data)
|
||||
brotliWriter.Close()
|
||||
brotliWriter.Reset(io.Discard)
|
||||
brotliWriterPool.Put(brotliWriter)
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
case "gzip":
|
||||
b.headers.Set("Content-Encoding", "gzip")
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
gzipWriter := gzipWriterPool.Get().(*gzip.Writer)
|
||||
gzipWriter.Reset(b.w)
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
gzipWriter.Reset(io.Discard)
|
||||
gzipWriterPool.Put(gzipWriter)
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
case "deflate":
|
||||
b.headers.Set("Content-Encoding", "deflate")
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
flateWriter := flateWriterPool.Get().(*flate.Writer)
|
||||
flateWriter.Reset(b.w)
|
||||
flateWriter.Write(data)
|
||||
flateWriter.Close()
|
||||
flateWriter.Reset(io.Discard)
|
||||
flateWriterPool.Put(flateWriter)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ func TestIfNoneMatch(t *testing.T) {
|
||||
func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||
body := strings.Repeat("a", compressionThreshold+1)
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
r.Header.Set("Accept-Encoding", "br, gzip, deflate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type acceptEncodingParser struct {
|
||||
// accepted contains all encoding that particular parser instance advertises.
|
||||
accepted []string
|
||||
}
|
||||
|
||||
// AcceptEncoding creates parser instance for "Accept-Encoding" header values.
|
||||
// It accepts list of encodings recognized by user of this parser instance.
|
||||
func AcceptEncoding(accepted ...string) *acceptEncodingParser {
|
||||
return &acceptEncodingParser{accepted: accepted}
|
||||
}
|
||||
|
||||
// Parse parses input string according to [HTTP Semantics]
|
||||
// and returns first encoding that can be understood by us.
|
||||
//
|
||||
// Currently this function ignores set weights other than q=0.
|
||||
// Encodings with q=0 will not be considered.
|
||||
//
|
||||
// If string is empty or no encoding was accepted function returns "identity".
|
||||
//
|
||||
// For "identity;q=0" and "*;q=0" function returns an empty string. In that case,
|
||||
// if no other encoding was accepted, 406 Not Acceptable should be returned.
|
||||
//
|
||||
// [HTTP Semantics]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding.
|
||||
func (p *acceptEncodingParser) Parse(acceptEncoding string) string {
|
||||
accepted := "identity"
|
||||
|
||||
for enc := range strings.SplitSeq(acceptEncoding, ",") {
|
||||
enc = strings.TrimSpace(enc)
|
||||
|
||||
if qi := strings.IndexByte(enc, ';'); qi > -1 {
|
||||
qstr := strings.TrimPrefix(enc[qi:], ";")
|
||||
qstr = strings.TrimSpace(qstr)
|
||||
qstr = strings.TrimPrefix(qstr, "q=")
|
||||
|
||||
q, err := strconv.ParseFloat(qstr, 64)
|
||||
if err != nil {
|
||||
continue // Ignore weird float values.
|
||||
}
|
||||
|
||||
enc = strings.TrimSpace(enc[:qi])
|
||||
|
||||
if q == 0 && slices.Contains([]string{"identity", "*"}, enc) {
|
||||
accepted = "" // Explicitly disabled, so can't be used as fallback.
|
||||
continue
|
||||
}
|
||||
|
||||
if q == 0 {
|
||||
continue // Skipping unwanted.
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(p.accepted, enc) {
|
||||
continue // Skipping unsupported.
|
||||
}
|
||||
|
||||
accepted = enc
|
||||
break
|
||||
}
|
||||
|
||||
return accepted
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcceptEncoding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
acceptable := []string{
|
||||
"br", "gzip", "deflate",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptEncoding string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Empty input",
|
||||
acceptEncoding: "",
|
||||
want: "identity",
|
||||
},
|
||||
{
|
||||
name: "q=0 and identity",
|
||||
acceptEncoding: "identity;q=0",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "q=0 and *",
|
||||
acceptEncoding: "*;q=0",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "gzip",
|
||||
acceptEncoding: "gzip",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "gzip and br",
|
||||
acceptEncoding: "gzip,br",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "br and gzip",
|
||||
acceptEncoding: "br,gzip,deflate",
|
||||
want: "br",
|
||||
},
|
||||
{
|
||||
name: "unsupported encoding",
|
||||
acceptEncoding: "unknown",
|
||||
want: "identity",
|
||||
},
|
||||
{
|
||||
name: "empty encoding",
|
||||
acceptEncoding: ",",
|
||||
want: "identity",
|
||||
},
|
||||
{
|
||||
name: "multiple encodings and q=0",
|
||||
acceptEncoding: "gzip;q=0,br;q=0",
|
||||
want: "identity",
|
||||
},
|
||||
{
|
||||
// We want br here but weights are not supported.
|
||||
name: "multiple encodings and q values",
|
||||
acceptEncoding: "gzip;q=0.5,br;q=0.8",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "multiple encodings and wildcard",
|
||||
acceptEncoding: "*;q=0,gzip,br",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "multiple encodings and wildcard and q=0",
|
||||
acceptEncoding: "*;q=0,gzip,br;q=0",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
// We want br here but weights are not supported.
|
||||
name: "multiple encodings and wildcard and q values",
|
||||
acceptEncoding: "*;q=0.5,gzip;q=0.8,br",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "multiple encodings and wildcard and q values and q=0",
|
||||
acceptEncoding: "*;q=0.5,gzip;q=0.8,br;q=0",
|
||||
want: "gzip",
|
||||
},
|
||||
{
|
||||
name: "invalid q value",
|
||||
acceptEncoding: "gzip;q=abc,deflate",
|
||||
want: "deflate",
|
||||
},
|
||||
{
|
||||
name: "wrong spaces placing around q value",
|
||||
acceptEncoding: "gzip;q= 0.5, deflate;q=0.8",
|
||||
want: "deflate",
|
||||
},
|
||||
{
|
||||
name: "correct spaces placing around q value",
|
||||
acceptEncoding: "gzip ; q=0.5, deflate;q=0.8",
|
||||
want: "gzip",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Instantiate parser for each test to make sure it doesn't return cached values.
|
||||
parser := AcceptEncoding(acceptable...)
|
||||
|
||||
got := parser.Parse(test.acceptEncoding)
|
||||
if got != test.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", test.acceptEncoding, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAcceptEncoding(b *testing.B) {
|
||||
encoding := "identity;q=0,gzip,whatever"
|
||||
expected := "gzip"
|
||||
|
||||
parser := AcceptEncoding("br", "gzip", "deflate")
|
||||
|
||||
for b.Loop() {
|
||||
got := parser.Parse(encoding)
|
||||
if got != expected {
|
||||
b.Errorf("Parse(%q) = %q, want %q", encoding, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) ([]*http.Server, func()) {
|
||||
var servers []*http.Server
|
||||
|
||||
autocertTLSConfig, challengeServer := setupAutocert(store)
|
||||
@@ -39,6 +39,26 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
// Create a single certificate loader shared by all TLS servers
|
||||
// that use the same cert/key pair.
|
||||
var certLoader *certificateLoader
|
||||
if certFile != "" && keyFile != "" {
|
||||
hasTLSTarget := false
|
||||
for _, t := range targets {
|
||||
if t.mode == modeTLS || t.mode == modeUnixSocketTLS {
|
||||
hasTLSTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasTLSTarget {
|
||||
var err error
|
||||
certLoader, err = newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
printErrorAndExit("Unable to load TLS certificate from %s / %s: %v", certFile, keyFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range targets {
|
||||
srv := &http.Server{
|
||||
Addr: t.address,
|
||||
@@ -55,11 +75,11 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
case modeUnixSocket:
|
||||
startUnixSocketServer(srv, t.address)
|
||||
case modeUnixSocketTLS:
|
||||
startUnixSocketTLSServer(srv, t.address, t.certFile, t.keyFile)
|
||||
startUnixSocketTLSServer(srv, t.address, certLoader)
|
||||
case modeAutocertTLS:
|
||||
startAutoCertTLSServer(srv, autocertTLSConfig)
|
||||
case modeTLS:
|
||||
startTLSServer(srv, t.certFile, t.keyFile)
|
||||
startTLSServer(srv, certLoader)
|
||||
default:
|
||||
startHTTPServer(srv)
|
||||
}
|
||||
@@ -67,7 +87,11 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
|
||||
return servers
|
||||
certReloadFn := func() {}
|
||||
if certLoader != nil {
|
||||
certReloadFn = certLoader.Reload
|
||||
}
|
||||
return servers, certReloadFn
|
||||
}
|
||||
|
||||
type listenerMode int
|
||||
@@ -82,10 +106,8 @@ const (
|
||||
)
|
||||
|
||||
type listenTarget struct {
|
||||
address string
|
||||
mode listenerMode
|
||||
certFile string
|
||||
keyFile string
|
||||
address string
|
||||
mode listenerMode
|
||||
}
|
||||
|
||||
func determineListenTargets(addresses []string, certDomain, certFile, keyFile string) []listenTarget {
|
||||
@@ -111,13 +133,13 @@ func determineListenTargets(addresses []string, certDomain, certFile, keyFile st
|
||||
|
||||
switch {
|
||||
case isUnix && hasCertFiles:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS, certFile: certFile, keyFile: keyFile})
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS})
|
||||
case isUnix:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocket})
|
||||
case hasAutocert && (addr == ":https" || (i == 0 && strings.Contains(addr, ":"))):
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeAutocertTLS})
|
||||
case hasCertFiles:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeTLS, certFile: certFile, keyFile: keyFile})
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeTLS})
|
||||
default:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeHTTP})
|
||||
}
|
||||
@@ -195,16 +217,20 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketTLSServer(server *http.Server, socketFile, certFile, keyFile string) {
|
||||
func startUnixSocketTLSServer(server *http.Server, socketFile string, certLoader *certificateLoader) {
|
||||
server.TLSConfig = &tls.Config{
|
||||
GetCertificate: certLoader.getCertificate,
|
||||
// NextProtos intentionally nil — ServeTLS auto-configures it
|
||||
// based on the http2server GODEBUG setting.
|
||||
}
|
||||
|
||||
listener := createUnixSocketListener(socketFile)
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using a Unix socket",
|
||||
slog.String("socket", socketFile),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
if err := server.ServeTLS(listener, "", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}()
|
||||
@@ -219,7 +245,7 @@ func createUnixSocketListener(socketFile string) net.Listener {
|
||||
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
||||
if err := os.Chmod(socketFile, 0660); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
@@ -243,14 +269,23 @@ func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
}()
|
||||
}
|
||||
|
||||
func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
func startTLSServer(server *http.Server, certLoader *certificateLoader) {
|
||||
server.TLSConfig = &tls.Config{
|
||||
GetCertificate: certLoader.getCertificate,
|
||||
// NextProtos intentionally nil — ServeTLS auto-configures it
|
||||
// based on the http2server GODEBUG setting.
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.Addr)
|
||||
if err != nil {
|
||||
printErrorAndExit("TLS server failed to listen on %s: %v", server.Addr, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using a certificate",
|
||||
slog.String("listen_address", server.Addr),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
|
||||
if err := server.ServeTLS(listener, "", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -37,7 +39,7 @@ func TestDetermineListenTargets(t *testing.T) {
|
||||
certFile: "/path/to/cert.pem",
|
||||
keyFile: "/path/to/key.pem",
|
||||
expected: []listenTarget{
|
||||
{address: ":443", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
|
||||
{address: ":443", mode: modeTLS},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -94,7 +96,7 @@ func TestDetermineListenTargets(t *testing.T) {
|
||||
certFile: "/path/to/cert.pem",
|
||||
keyFile: "/path/to/key.pem",
|
||||
expected: []listenTarget{
|
||||
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
|
||||
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,8 +105,8 @@ func TestDetermineListenTargets(t *testing.T) {
|
||||
certFile: "/path/to/cert.pem",
|
||||
keyFile: "/path/to/key.pem",
|
||||
expected: []listenTarget{
|
||||
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
|
||||
{address: ":8080", mode: modeTLS, certFile: "/path/to/cert.pem", keyFile: "/path/to/key.pem"},
|
||||
{address: "/var/run/miniflux.sock", mode: modeUnixSocketTLS},
|
||||
{address: ":8080", mode: modeTLS},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,3 +189,34 @@ func TestAnyTLS(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUnixSocketListenerPermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix sockets are not supported on Windows")
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("/tmp", "miniflux-*.sock")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to allocate Unix socket path: %v", err)
|
||||
}
|
||||
socketFile := tempFile.Name()
|
||||
if err := tempFile.Close(); err != nil {
|
||||
t.Fatalf("Unable to close temporary file: %v", err)
|
||||
}
|
||||
if err := os.Remove(socketFile); err != nil {
|
||||
t.Fatalf("Unable to prepare Unix socket path: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(socketFile) })
|
||||
|
||||
listener := createUnixSocketListener(socketFile)
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
|
||||
fileInfo, err := os.Stat(socketFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to stat Unix socket: %v", err)
|
||||
}
|
||||
|
||||
if got, want := fileInfo.Mode().Perm(), os.FileMode(0660); got != want {
|
||||
t.Errorf("Unix socket permissions = %04o, want %04o", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// certificateLoader loads and caches a TLS certificate from disk, and
|
||||
// provides a reload method that can be triggered on SIGHUP.
|
||||
type certificateLoader struct {
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
|
||||
func newCertificateLoader(certFile, keyFile string) (*certificateLoader, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loader := &certificateLoader{
|
||||
cert: &cert,
|
||||
certFile: filepath.Clean(certFile),
|
||||
keyFile: filepath.Clean(keyFile),
|
||||
}
|
||||
|
||||
slog.Info("TLS certificate loaded",
|
||||
slog.String("cert_file", loader.certFile),
|
||||
slog.String("key_file", loader.keyFile),
|
||||
)
|
||||
|
||||
return loader, nil
|
||||
}
|
||||
|
||||
// getCertificate returns the currently cached TLS certificate. It satisfies
|
||||
// the tls.Config.GetCertificate callback and is called by the TLS layer on
|
||||
// every handshake.
|
||||
func (cl *certificateLoader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
return cl.cert, nil
|
||||
}
|
||||
|
||||
// Reload loads the certificate and key from disk and replaces the cached
|
||||
// copy. If loading fails, the existing certificate is kept and the error
|
||||
// is logged.
|
||||
func (cl *certificateLoader) Reload() {
|
||||
cert, err := tls.LoadX509KeyPair(cl.certFile, cl.keyFile)
|
||||
if err != nil {
|
||||
slog.Error("Unable to reload TLS certificate",
|
||||
slog.String("cert_file", cl.certFile),
|
||||
slog.String("key_file", cl.keyFile),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
cl.mu.Lock()
|
||||
cl.cert = &cert
|
||||
cl.mu.Unlock()
|
||||
|
||||
slog.Info("TLS certificate reloaded successfully",
|
||||
slog.String("cert_file", cl.certFile),
|
||||
slog.String("key_file", cl.keyFile),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateTestCert creates a self-signed certificate and key and writes them
|
||||
// to PEM files in the given directory. Returns cert and key file paths.
|
||||
func generateTestCert(t *testing.T, dir, prefix string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
serial := big.NewInt(time.Now().UnixNano())
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: prefix + ".example.com",
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(1 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certFile := filepath.Join(dir, prefix+".pem")
|
||||
keyFile := filepath.Join(dir, prefix+"-key.pem")
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
|
||||
if err := os.WriteFile(certFile, certPEM, 0600); err != nil {
|
||||
t.Fatalf("failed to write cert file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("failed to write key file: %v", err)
|
||||
}
|
||||
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
// certLoaderSerial extracts the serial number of the first certificate
|
||||
// returned by the loader's getCertificate callback.
|
||||
func certLoaderSerial(cl *certificateLoader) *big.Int {
|
||||
cert, err := cl.getCertificate(nil)
|
||||
if err != nil || cert == nil {
|
||||
return nil
|
||||
}
|
||||
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return x509Cert.SerialNumber
|
||||
}
|
||||
|
||||
// TestCertificateLoaderInitialLoad verifies that a new certificateLoader
|
||||
// loads the certificate successfully and serves it via getCertificate.
|
||||
func TestCertificateLoaderInitialLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := generateTestCert(t, dir, "initial")
|
||||
|
||||
cl, err := newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertificateLoader failed: %v", err)
|
||||
}
|
||||
|
||||
cert, err := cl.getCertificate(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getCertificate failed: %v", err)
|
||||
}
|
||||
if cert == nil {
|
||||
t.Fatal("getCertificate returned nil certificate")
|
||||
}
|
||||
if len(cert.Certificate) == 0 {
|
||||
t.Fatal("certificate chain is empty")
|
||||
}
|
||||
if certLoaderSerial(cl) == nil {
|
||||
t.Fatal("unable to parse certificate serial")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateLoaderReload verifies that Reload picks up a new certificate
|
||||
// written to disk.
|
||||
func TestCertificateLoaderReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := generateTestCert(t, dir, "reload")
|
||||
|
||||
cl, err := newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertificateLoader failed: %v", err)
|
||||
}
|
||||
|
||||
origSerial := certLoaderSerial(cl)
|
||||
if origSerial == nil {
|
||||
t.Fatal("unable to parse original certificate serial")
|
||||
}
|
||||
|
||||
// Write a new certificate to the same file paths.
|
||||
generateTestCert(t, dir, "reload")
|
||||
|
||||
cl.Reload()
|
||||
|
||||
newSerial := certLoaderSerial(cl)
|
||||
if newSerial == nil {
|
||||
t.Fatal("unable to parse certificate serial after reload")
|
||||
}
|
||||
if origSerial.Cmp(newSerial) == 0 {
|
||||
t.Fatal("certificate serial did not change after reload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateLoaderReloadFailureKeepsOldCert verifies that if Reload fails
|
||||
// the old certificate is preserved.
|
||||
func TestCertificateLoaderReloadFailureKeepsOldCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := generateTestCert(t, dir, "keep-old")
|
||||
|
||||
cl, err := newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertificateLoader failed: %v", err)
|
||||
}
|
||||
|
||||
origSerial := certLoaderSerial(cl)
|
||||
if origSerial == nil {
|
||||
t.Fatal("unable to parse original certificate serial")
|
||||
}
|
||||
|
||||
// Corrupt the key file.
|
||||
if err := os.WriteFile(keyFile, []byte("not a valid PEM key"), 0600); err != nil {
|
||||
t.Fatalf("failed to write corrupted key file: %v", err)
|
||||
}
|
||||
|
||||
cl.Reload()
|
||||
|
||||
curSerial := certLoaderSerial(cl)
|
||||
if curSerial == nil {
|
||||
t.Fatal("unable to parse certificate serial after failed reload")
|
||||
}
|
||||
if origSerial.Cmp(curSerial) != 0 {
|
||||
t.Fatal("certificate changed after a failed reload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateLoaderNilClientHello verifies getCertificate handles a nil
|
||||
// *tls.ClientHelloInfo argument.
|
||||
func TestCertificateLoaderNilClientHello(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := generateTestCert(t, dir, "nil-hello")
|
||||
|
||||
cl, err := newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertificateLoader failed: %v", err)
|
||||
}
|
||||
|
||||
cert, err := cl.getCertificate(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getCertificate(nil) returned error: %v", err)
|
||||
}
|
||||
if cert == nil {
|
||||
t.Fatal("getCertificate(nil) returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateLoaderClientHelloInfo verifies that getCertificate works
|
||||
// when called with a real *tls.ClientHelloInfo.
|
||||
func TestCertificateLoaderClientHelloInfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := generateTestCert(t, dir, "sni")
|
||||
|
||||
cl, err := newCertificateLoader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertificateLoader failed: %v", err)
|
||||
}
|
||||
|
||||
hello := &tls.ClientHelloInfo{
|
||||
ServerName: "sni.example.com",
|
||||
}
|
||||
cert, err := cl.getCertificate(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("getCertificate with ClientHelloInfo failed: %v", err)
|
||||
}
|
||||
if cert == nil {
|
||||
t.Fatal("getCertificate with ClientHelloInfo returned nil")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -6,21 +6,13 @@
|
||||
package cubox // import "miniflux.app/v2/internal/integration/cubox"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
apiLink string
|
||||
}
|
||||
@@ -34,28 +26,15 @@ func (c *Client) SaveLink(entryURL string) error {
|
||||
return errors.New("cubox: missing API link")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&card{
|
||||
Type: "url",
|
||||
Content: entryURL,
|
||||
})
|
||||
response, err := client.NewRequestBuilder(c.apiLink).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&card{
|
||||
Type: "url",
|
||||
Content: entryURL,
|
||||
}).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to encode request body: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
return fmt.Errorf("cubox: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -6,21 +6,15 @@
|
||||
package discord // import "miniflux.app/v2/internal/integration/discord"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
const discordMsgColor = 5793266
|
||||
|
||||
type Client struct {
|
||||
@@ -33,58 +27,47 @@ func NewClient(webhookURL string) *Client {
|
||||
|
||||
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&discordMessage{
|
||||
Embeds: []discordEmbed{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: discordMsgColor,
|
||||
Fields: []discordFields{
|
||||
{
|
||||
Name: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Name: "Article link",
|
||||
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
|
||||
},
|
||||
{
|
||||
Name: "Author",
|
||||
Value: entry.Author,
|
||||
Inline: true,
|
||||
},
|
||||
{
|
||||
Name: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Inline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Discord notification",
|
||||
slog.String("webhookURL", c.webhookURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
response, err := client.NewRequestBuilder(c.webhookURL).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&discordMessage{
|
||||
Embeds: []discordEmbed{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: discordMsgColor,
|
||||
Fields: []discordFields{
|
||||
{
|
||||
Name: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Name: "Article link",
|
||||
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
|
||||
},
|
||||
{
|
||||
Name: "Author",
|
||||
Value: entry.Author,
|
||||
Inline: true,
|
||||
},
|
||||
{
|
||||
Name: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Inline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
return fmt.Errorf("discord: %w", 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)
|
||||
|
||||
@@ -5,20 +5,14 @@ package espial // import "miniflux.app/v2/internal/integration/espial"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -38,30 +32,18 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
return fmt.Errorf("espial: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&espialDocument{
|
||||
Title: entryTitle,
|
||||
URL: entryURL,
|
||||
ToRead: true,
|
||||
Tags: espialTags,
|
||||
})
|
||||
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&espialDocument{
|
||||
Title: entryTitle,
|
||||
URL: entryURL,
|
||||
ToRead: true,
|
||||
Tags: espialTags,
|
||||
}).
|
||||
WithHeader("Authorization", "ApiKey "+c.apiKey).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to send request: %v", err)
|
||||
return fmt.Errorf("espial: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
package linkace // import "miniflux.app/v2/internal/integration/linkace"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -45,31 +38,20 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Private: c.private,
|
||||
CheckDisabled: c.checkDisabled,
|
||||
})
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&createItemRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Private: c.private,
|
||||
CheckDisabled: c.checkDisabled,
|
||||
}).
|
||||
WithHeader("Accept", "application/json").
|
||||
WithHeader("Authorization", "Bearer "+c.apiKey).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to send request: %v", err)
|
||||
return fmt.Errorf("linkace: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
package linkding // import "miniflux.app/v2/internal/integration/linkding"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -45,30 +38,18 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkdingBookmark{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Unread: c.unread,
|
||||
})
|
||||
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&linkdingBookmark{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Unread: c.unread,
|
||||
}).
|
||||
WithHeader("Authorization", "Token "+c.apiKey).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to send request: %v", err)
|
||||
return fmt.Errorf("linkding: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -59,25 +52,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
payload.Collection = &linkwardenCollection{ID: c.collectionID}
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(payload)
|
||||
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(payload).
|
||||
WithHeader("Authorization", "Bearer "+c.apiKey).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to send request: %v", err)
|
||||
return fmt.Errorf("linkwarden: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,19 +4,13 @@
|
||||
package notion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
apiToken string
|
||||
pageID string
|
||||
@@ -32,36 +26,25 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
|
||||
}
|
||||
|
||||
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
|
||||
requestBody, err := json.Marshal(¬ionDocument{
|
||||
Children: []block{
|
||||
{
|
||||
Object: "block",
|
||||
Type: "bookmark",
|
||||
Bookmark: bookmarkObject{
|
||||
Caption: []any{},
|
||||
URL: entryURL,
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPatch).
|
||||
WithJSON(¬ionDocument{
|
||||
Children: []block{
|
||||
{
|
||||
Object: "block",
|
||||
Type: "bookmark",
|
||||
Bookmark: bookmarkObject{
|
||||
Caption: []any{},
|
||||
URL: entryURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}).
|
||||
WithHeader("Notion-Version", "2022-06-28").
|
||||
WithHeader("Authorization", "Bearer "+c.apiToken).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Notion-Version", "2022-06-28")
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notion: unable to send request: %v", err)
|
||||
return fmt.Errorf("notion: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,21 +4,14 @@
|
||||
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
collectionID string
|
||||
@@ -26,7 +19,13 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(token, collectionID, tags string) *Client {
|
||||
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
|
||||
var tagList []string
|
||||
for tag := range strings.SplitSeq(tags, ",") {
|
||||
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
|
||||
tagList = append(tagList, trimmedTag)
|
||||
}
|
||||
}
|
||||
return &Client{token: token, collectionID: collectionID, tags: tagList}
|
||||
}
|
||||
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
@@ -35,30 +34,18 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
return errors.New("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
requestBodyJson, err := json.Marshal(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
})
|
||||
response, err := client.NewRequestBuilder("https://api.raindrop.io/rest/v1/raindrop").
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
}).
|
||||
WithHeader("Authorization", "Bearer "+c.token).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
return fmt.Errorf("raindrop: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -73,7 +60,7 @@ type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection"`
|
||||
Tags []string `json:"tags"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type collection struct {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package raindrop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClientTagParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tags string
|
||||
want []string
|
||||
}{
|
||||
{name: "empty string produces no tags", tags: "", want: nil},
|
||||
{name: "single tag", tags: "news", want: []string{"news"}},
|
||||
{name: "multiple tags", tags: "news,tech", want: []string{"news", "tech"}},
|
||||
{name: "whitespace is trimmed", tags: " news , tech ", want: []string{"news", "tech"}},
|
||||
{name: "empty items are dropped", tags: "news,,tech,", want: []string{"news", "tech"}},
|
||||
{name: "only separators produce no tags", tags: ", ,", want: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient("token", "collection", tt.tags)
|
||||
if !slices.Equal(client.tags, tt.want) {
|
||||
t.Errorf("NewClient(%q) tags = %#v, want %#v", tt.tags, client.tags, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadOmitsEmptyTags(t *testing.T) {
|
||||
payload, err := json.Marshal(&raindrop{Link: "https://example.com", Title: "Example"})
|
||||
if err != nil {
|
||||
t.Fatalf("unable to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatalf("unable to unmarshal payload: %v", err)
|
||||
}
|
||||
|
||||
if _, found := fields["tags"]; found {
|
||||
t.Errorf("payload without tags should omit the tags field, got %s", payload)
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,14 @@
|
||||
package readwise // import "miniflux.app/v2/internal/integration/readwise"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
)
|
||||
const readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
@@ -36,27 +28,15 @@ func (c *Client) CreateDocument(entryURL string) error {
|
||||
return errors.New("readwise: missing API key")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&readwiseDocument{
|
||||
URL: entryURL,
|
||||
})
|
||||
|
||||
response, err := client.NewRequestBuilder(readwiseApiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&readwiseDocument{
|
||||
URL: entryURL,
|
||||
}).
|
||||
WithHeader("Authorization", "Token "+c.apiKey).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("readwise: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("readwise: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readwise: unable to send request: %v", err)
|
||||
return fmt.Errorf("readwise: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,24 +4,18 @@
|
||||
package shaarli // import "miniflux.app/v2/internal/integration/shaarli"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiSecret string
|
||||
@@ -41,30 +35,18 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("shaarli: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&addLinkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Private: true,
|
||||
})
|
||||
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&addLinkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Private: true,
|
||||
}).
|
||||
WithHeader("Accept", "application/json").
|
||||
WithHeader("Authorization", "Bearer "+c.generateBearerToken()).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("shaarli: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("shaarli: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shaarli: unable to send request: %v", err)
|
||||
return fmt.Errorf("shaarli: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
package shiori // import "miniflux.app/v2/internal/integration/shiori"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
@@ -44,34 +38,21 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&addBookmarkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Excerpt: "",
|
||||
CreateArchive: true,
|
||||
CreateEbook: false,
|
||||
Public: 0,
|
||||
Tags: make([]string, 0),
|
||||
})
|
||||
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&addBookmarkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Excerpt: "",
|
||||
CreateArchive: true,
|
||||
CreateEbook: false,
|
||||
Public: 0,
|
||||
Tags: make([]string, 0),
|
||||
}).
|
||||
WithHeader("Authorization", "Bearer "+token).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to send request: %v", err)
|
||||
return fmt.Errorf("shiori: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -88,25 +69,13 @@ func (c *Client) authenticate() (string, error) {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
|
||||
response, err := client.NewRequestBuilder(apiEndpoint).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&authRequest{Username: c.username, Password: c.password, RememberMe: false}).
|
||||
WithHeader("Accept", "application/json").
|
||||
Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to send request: %v", err)
|
||||
return "", fmt.Errorf("shiori: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -6,21 +6,15 @@
|
||||
package slack // import "miniflux.app/v2/internal/integration/slack"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
const slackMsgColor = "#5865F2"
|
||||
|
||||
type Client struct {
|
||||
@@ -33,62 +27,51 @@ func NewClient(webhookURL string) *Client {
|
||||
|
||||
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&slackMessage{
|
||||
Attachments: []slackAttachments{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: slackMsgColor,
|
||||
Fields: []slackFields{
|
||||
{
|
||||
Title: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article title",
|
||||
Value: entry.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article link",
|
||||
Value: entry.URL,
|
||||
},
|
||||
{
|
||||
Title: "Author",
|
||||
Value: entry.Author,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Short: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Slack notification",
|
||||
slog.String("webhookURL", c.webhookURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
response, err := client.NewRequestBuilder(c.webhookURL).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSON(&slackMessage{
|
||||
Attachments: []slackAttachments{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: slackMsgColor,
|
||||
Fields: []slackFields{
|
||||
{
|
||||
Title: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article title",
|
||||
Value: entry.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article link",
|
||||
Value: entry.URL,
|
||||
},
|
||||
{
|
||||
Title: "Author",
|
||||
Value: entry.Author,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Short: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
return fmt.Errorf("slack: %w", 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)
|
||||
|
||||
@@ -4,23 +4,18 @@
|
||||
package webhook // import "miniflux.app/v2/internal/integration/webhook"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
|
||||
NewEntriesEventType = "new_entries"
|
||||
SaveEntryEventType = "save_entry"
|
||||
)
|
||||
@@ -124,20 +119,14 @@ func (c *Client) makeRequest(eventType string, payload any) error {
|
||||
return fmt.Errorf("webhook: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
response, err := client.NewRequestBuilder(c.webhookURL).
|
||||
WithMethod(http.MethodPost).
|
||||
WithJSONBody(requestBody).
|
||||
WithHeader("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody)).
|
||||
WithHeader("X-Miniflux-Event-Type", eventType).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
|
||||
request.Header.Set("X-Miniflux-Event-Type", eventType)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: unable to send request: %v", err)
|
||||
return fmt.Errorf("webhook: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type translationDict struct {
|
||||
@@ -15,19 +16,37 @@ type translationDict struct {
|
||||
}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
// defaultCatalog is populated lazily by getTranslationDict, which runs on
|
||||
// concurrent request goroutines, so every access must hold defaultCatalogMutex.
|
||||
var (
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
defaultCatalogMutex sync.RWMutex
|
||||
)
|
||||
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func getTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
return translationDict{}, err
|
||||
}
|
||||
defaultCatalogMutex.RLock()
|
||||
dict, found := defaultCatalog[language]
|
||||
defaultCatalogMutex.RUnlock()
|
||||
if found {
|
||||
return dict, nil
|
||||
}
|
||||
return defaultCatalog[language], nil
|
||||
|
||||
defaultCatalogMutex.Lock()
|
||||
defer defaultCatalogMutex.Unlock()
|
||||
|
||||
if dict, found := defaultCatalog[language]; found {
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
dict, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return translationDict{}, err
|
||||
}
|
||||
defaultCatalog[language] = dict
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
func loadTranslationFile(language string) (translationDict, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,35 @@ func TestParser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTranslationDictConcurrency exercises the lazy population of the
|
||||
// catalog from concurrent goroutines, as HTTP request handlers do. It must be
|
||||
// run with the race detector enabled to catch unsynchronized catalog access.
|
||||
func TestGetTranslationDictConcurrency(t *testing.T) {
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
|
||||
const iterations = 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < iterations; i++ {
|
||||
for language := range AvailableLanguages {
|
||||
wg.Add(1)
|
||||
go func(language string) {
|
||||
defer wg.Done()
|
||||
|
||||
dict, err := getTranslationDict(language)
|
||||
if err != nil {
|
||||
t.Errorf(`Unable to get translation dictionary for language %q: %v`, language, err)
|
||||
return
|
||||
}
|
||||
if len(dict.singulars) == 0 {
|
||||
t.Errorf(`The translation dictionary for language %q should not be empty`, language)
|
||||
}
|
||||
}(language)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestLoadCatalog(t *testing.T) {
|
||||
for language := range AvailableLanguages {
|
||||
_, err := loadTranslationFile(language)
|
||||
@@ -111,6 +141,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"`
|
||||
@@ -29,6 +33,7 @@ type Entry struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Language string `json:"language"`
|
||||
Date time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
@@ -76,6 +81,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.
|
||||
|
||||
@@ -28,6 +28,7 @@ type Feed struct {
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Language string `json:"language"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
NextCheckAt time.Time `json:"next_check_at"`
|
||||
EtagHeader string `json:"etag_header"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ import (
|
||||
type atom03Feed struct {
|
||||
Version string `xml:"version,attr"`
|
||||
|
||||
// Language is the natural language of the feed, declared by an
|
||||
// xml:lang attribute on the atom:feed element. The tag is
|
||||
// namespace-qualified so that lang attributes from other namespaces
|
||||
// cannot override the real xml:lang value.
|
||||
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
|
||||
|
||||
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
|
||||
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
|
||||
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
|
||||
@@ -47,6 +53,12 @@ type atom03Entry struct {
|
||||
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
|
||||
ID string `xml:"id"`
|
||||
|
||||
// Language is the natural language of the entry, declared by an
|
||||
// xml:lang attribute on the atom:entry element. The tag is
|
||||
// namespace-qualified so that lang attributes from other namespaces
|
||||
// cannot override the real xml:lang value.
|
||||
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
|
||||
|
||||
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
|
||||
// atom:entry elements MUST have exactly one "atom:title" element.
|
||||
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
|
||||
|
||||
@@ -5,11 +5,13 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/date"
|
||||
"miniflux.app/v2/internal/reader/language"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
@@ -19,7 +21,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 +32,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 +40,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.
|
||||
@@ -47,9 +48,19 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed.Title = feed.SiteURL
|
||||
}
|
||||
|
||||
feed.Language = language.Normalize(a.atomFeed.Language)
|
||||
|
||||
for _, atomEntry := range a.atomFeed.Entries {
|
||||
entry := model.NewEntry()
|
||||
|
||||
// Populate the entry language. xml:lang applies to the whole
|
||||
// subtree it is declared on, so an entry without its own
|
||||
// xml:lang inherits the feed-level value.
|
||||
entry.Language = language.Normalize(atomEntry.Language)
|
||||
if entry.Language == "" {
|
||||
entry.Language = language.Normalize(a.atomFeed.Language)
|
||||
}
|
||||
|
||||
// Populate the entry URL.
|
||||
entry.URL = atomEntry.Links.originalLink()
|
||||
if entry.URL != "" {
|
||||
@@ -69,6 +80,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 +93,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()
|
||||
}
|
||||
|
||||
@@ -294,3 +294,47 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
|
||||
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAtom03WithLanguage(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed version="0.3" xmlns="http://purl.org/atom/ns#" xmlns:foo="http://example.org/ns" xml:lang="fr-CA" foo:lang="zz">
|
||||
<title>dive into mark</title>
|
||||
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
|
||||
<modified>2003-12-13T18:30:02Z</modified>
|
||||
<entry xml:lang="pt-BR" foo:lang="zz">
|
||||
<title>Atom 0.3 snapshot</title>
|
||||
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
|
||||
<id>tag:diveintomark.org,2003:3.2397</id>
|
||||
<issued>2003-12-13T08:29:29-04:00</issued>
|
||||
<modified>2003-12-13T18:30:02Z</modified>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Atom 0.3 second snapshot</title>
|
||||
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/14/atom03"/>
|
||||
<id>tag:diveintomark.org,2003:3.2398</id>
|
||||
<issued>2003-12-14T08:29:29-04:00</issued>
|
||||
<modified>2003-12-14T18:30:02Z</modified>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Language != "fr-ca" {
|
||||
t.Errorf("Incorrect language, got: %q", feed.Language)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 2 {
|
||||
t.Fatalf("Expected 2 entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Language != "pt-br" {
|
||||
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
|
||||
}
|
||||
|
||||
if feed.Entries[1].Language != "fr-ca" {
|
||||
t.Errorf("Expected entry to inherit feed language, got: %q", feed.Entries[1].Language)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ import (
|
||||
type atom10Feed struct {
|
||||
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
||||
|
||||
// Language is the natural language of the feed, declared by an
|
||||
// xml:lang attribute on the atom:feed element. The tag is
|
||||
// namespace-qualified so that lang attributes from other namespaces
|
||||
// cannot override the real xml:lang value.
|
||||
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
|
||||
|
||||
// The "atom:id" element conveys a permanent, universally unique
|
||||
// identifier for an entry or feed.
|
||||
//
|
||||
@@ -96,6 +102,12 @@ type atom10Entry struct {
|
||||
// atom:entry elements MUST contain exactly one atom:id element.
|
||||
ID string `xml:"http://www.w3.org/2005/Atom id"`
|
||||
|
||||
// Language is the natural language of the entry, declared by an
|
||||
// xml:lang attribute on the atom:entry element. The tag is
|
||||
// namespace-qualified so that lang attributes from other namespaces
|
||||
// cannot override the real xml:lang value.
|
||||
Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
|
||||
|
||||
// The "atom:title" element is a Text construct that conveys a human-
|
||||
// readable title for an entry or feed.
|
||||
//
|
||||
|
||||
@@ -5,8 +5,6 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,6 +12,7 @@ import (
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/date"
|
||||
"miniflux.app/v2/internal/reader/language"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
@@ -22,12 +21,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 +33,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 +41,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.
|
||||
@@ -58,16 +52,20 @@ func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||
// Populate the feed description.
|
||||
feed.Description = a.atomFeed.Subtitle.body()
|
||||
|
||||
feed.Language = language.Normalize(a.atomFeed.Language)
|
||||
|
||||
// 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 +84,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 == "" {
|
||||
@@ -104,44 +112,52 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the entry language. xml:lang applies to the whole
|
||||
// subtree it is declared on, so an entry without its own
|
||||
// xml:lang inherits the feed-level value.
|
||||
entry.Language = language.Normalize(atomEntry.Language)
|
||||
if entry.Language == "" {
|
||||
entry.Language = language.Normalize(a.atomFeed.Language)
|
||||
}
|
||||
|
||||
// Populate the entry author.
|
||||
authors := atomEntry.Authors.personNames()
|
||||
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 +183,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 +215,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 +237,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 +266,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,208 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithLanguage(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr-CA">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Language != "fr-ca" {
|
||||
t.Errorf("Incorrect language, got: %q", feed.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithoutLanguage(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/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Language != "" {
|
||||
t.Errorf("Expected empty language, got: %q", feed.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithLanguage(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
<entry xml:lang="fr-CA">
|
||||
<title>Bonjour</title>
|
||||
<link href="http://example.org/2003/12/13/bonjour"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Language != "fr-ca" {
|
||||
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithoutLanguageInheritsFeedLanguage(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
<entry>
|
||||
<title>Hello</title>
|
||||
<link href="http://example.org/2003/12/13/hello"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Language != "en" {
|
||||
t.Errorf("Expected entry to inherit feed language, got: %q", feed.Entries[0].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithoutAnyLanguage(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/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
<entry>
|
||||
<title>Hello</title>
|
||||
<link href="http://example.org/2003/12/13/hello"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Language != "" {
|
||||
t.Errorf("Expected empty entry language, got: %q", feed.Entries[0].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithForeignLangAttribute(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:foo="http://example.org/ns" xml:lang="fr-CA" foo:lang="zz">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
<entry xml:lang="fr-CA" foo:lang="zz">
|
||||
<title>Bonjour</title>
|
||||
<link href="http://example.org/2003/12/13/bonjour"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Language != "fr-ca" {
|
||||
t.Errorf("Incorrect language, got: %q", feed.Language)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Language != "fr-ca" {
|
||||
t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithUnqualifiedLangAttribute(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" lang="de">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://example.org/"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Language != "" {
|
||||
t.Errorf("Expected empty language for unqualified lang attribute, got: %q", feed.Language)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
|
||||
|
||||
type DublinCoreChannelElement struct {
|
||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
DublinCoreLanguage string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
}
|
||||
|
||||
type DublinCoreItemElement struct {
|
||||
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
DublinCoreLanguage string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -540,25 +563,25 @@ func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
|
||||
// Create a slow server
|
||||
// Create a slow server that blocks until the client disconnects, so
|
||||
// server.Close() does not have to wait for a fixed sleep to elapse.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
builder := NewRequestBuilder()
|
||||
start := time.Now()
|
||||
_, err := builder.WithTimeout(1 * time.Second).ExecuteRequest(server.URL)
|
||||
_, err := builder.WithTimeout(100 * time.Millisecond).ExecuteRequest(server.URL)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
|
||||
// Should timeout around 1 second, allow some margin
|
||||
if duration > 1500*time.Millisecond {
|
||||
t.Errorf("Expected timeout around 1s, took %v", duration)
|
||||
// Should timeout around 100ms, allow some margin
|
||||
if duration > 500*time.Millisecond {
|
||||
t.Errorf("Expected timeout around 100ms, took %v", duration)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -179,6 +167,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.BlockFilterEntryRules = feedCreationRequest.BlockFilterEntryRules
|
||||
subscription.KeepFilterEntryRules = feedCreationRequest.KeepFilterEntryRules
|
||||
subscription.HideGlobally = feedCreationRequest.HideGlobally
|
||||
subscription.NoMediaPlayer = feedCreationRequest.NoMediaPlayer
|
||||
subscription.EtagHeader = responseHandler.ETag()
|
||||
subscription.LastModifiedHeader = responseHandler.LastModified()
|
||||
subscription.FeedURL = responseHandler.EffectiveURL()
|
||||
@@ -232,22 +221,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))
|
||||
@@ -351,7 +341,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
originalFeed.EtagHeader = responseHandler.ETag()
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
|
||||
originalFeed.Language = updatedFeed.Language
|
||||
originalFeed.IconURL = updatedFeed.IconURL
|
||||
iconChecker := icon.NewIconChecker(store, originalFeed)
|
||||
if forceRefresh {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user