Compare commits
274 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f2e0d819 | |||
| 510d225b06 | |||
| ecdc3569e3 | |||
| f96bee0d61 | |||
| 15505142fd | |||
| 6ddddbc4c2 | |||
| 063e3f14f0 | |||
| ae1f1351bf | |||
| 5de36b613c | |||
| 2ac3073583 | |||
| ae6ac73ce4 | |||
| 9f4b2ef9fc | |||
| bcb2cf2aa2 | |||
| 530b0c5739 | |||
| b0d25c1c68 | |||
| 8699a1d119 | |||
| a1539659f3 | |||
| 1975bc6339 | |||
| f72957e807 | |||
| ce5d84e956 | |||
| 49d8f1dafb | |||
| 91860edab0 | |||
| e49c75fc56 | |||
| a14a180313 | |||
| 684ea3d224 | |||
| 4ce8151a8f | |||
| 7d8ffd2eb0 | |||
| 05cd8da81f | |||
| f22c08832a | |||
| f4de355374 | |||
| f963ea35a5 | |||
| 87d7891600 | |||
| 939698fd66 | |||
| 350df63d99 | |||
| 0cfd0798b9 | |||
| f4b393748d | |||
| 102989656b | |||
| 533ff80744 | |||
| 83ea3d1912 | |||
| 651fbd1560 | |||
| 5e766332be | |||
| 2717336d2c | |||
| 9a774083ab | |||
| 79381e6f9a | |||
| 161ed71eb0 | |||
| c896bafdaa | |||
| 66996e3ffa | |||
| f050f23bda | |||
| 50088405e0 | |||
| d235a63138 | |||
| 483da488d8 | |||
| e01a6100ca | |||
| a725476164 | |||
| 47e304f343 | |||
| 9dfed35946 | |||
| 95f5f1e77d | |||
| a08f598cc8 | |||
| 0e1523551b | |||
| eff9502462 | |||
| ab94f42ad1 | |||
| f66772e911 | |||
| 43456daddf | |||
| 863d6039ad | |||
| fc3e548be7 | |||
| 096a15ef14 | |||
| 0976efd163 | |||
| 06e36c3e54 | |||
| 39772c33f0 | |||
| 098270cb14 | |||
| 24c65304a7 | |||
| 3747e686af | |||
| cc1d39efe1 | |||
| bdd7f4f365 | |||
| 7628a214f3 | |||
| 20545d28e9 | |||
| 771977407c | |||
| 0642e8a3ea | |||
| 059ec55f52 | |||
| 9fd6f44311 | |||
| 018128e109 | |||
| 3f05747a78 | |||
| 7a5b4b109e | |||
| c2661ca1c4 | |||
| 30ad65e03b | |||
| 9a0e357842 | |||
| d4f362ad31 | |||
| 10bdbf82b9 | |||
| f467c2daa1 | |||
| 6b06c9f4f4 | |||
| 30ede1caa1 | |||
| 16902a2297 | |||
| 08de9546e3 | |||
| cf7474a2d3 | |||
| 7892cb8546 | |||
| fc68e33681 | |||
| 622d757a32 | |||
| f74870d796 | |||
| bad9411d63 | |||
| b7da3634e4 | |||
| 8fc7d1f3c2 | |||
| 7d80bbd070 | |||
| cd01413b9b | |||
| 0909323ae3 | |||
| 6543d652a6 | |||
| 64baebad6f | |||
| d5e68025d4 | |||
| e4338950a0 | |||
| c395924fa0 | |||
| 9467d5deb9 | |||
| 55821b9fba | |||
| 45e222632b | |||
| a684b6ac73 | |||
| 1e8ce07585 | |||
| 10f95e9f96 | |||
| 54259c6176 | |||
| cdf0632f08 | |||
| 08113e50bb | |||
| 56c80f3085 | |||
| bbd67302fa | |||
| 2916831cb1 | |||
| 9702c6269f | |||
| 42b9c7ea13 | |||
| f2f9fdd867 | |||
| ee6c390aac | |||
| 15a532b991 | |||
| 45f97c6907 | |||
| deef74e75b | |||
| 182a010ea7 | |||
| 18920385ff | |||
| 75753ce8b3 | |||
| 53095eb76c | |||
| 6569367531 | |||
| ecd663a094 | |||
| 68bc5a92e2 | |||
| 2e60923236 | |||
| 0986f73093 | |||
| 76143ec1a6 | |||
| ef47db4a36 | |||
| 8bbd60dac0 | |||
| 0172d31694 | |||
| 1b7af66fdc | |||
| de9cd88a06 | |||
| b2316442cf | |||
| e0b1ec24e3 | |||
| bca47d7dfb | |||
| 19f72e8ea2 | |||
| 26d9195d21 | |||
| 5f3049d1ce | |||
| c591ea335f | |||
| 8df6b823ee | |||
| fd75311621 | |||
| 4e8a0e0639 | |||
| 96989e2a6f | |||
| 4f504499fa | |||
| 382e179e19 | |||
| 47ced3e1f8 | |||
| e2f225fde9 | |||
| 932e013590 | |||
| d5c37d7d12 | |||
| b160f92ff8 | |||
| 31f077cccc | |||
| 062641330a | |||
| c61c20247d | |||
| 873416f4be | |||
| f8171fe69f | |||
| ba684cabfd | |||
| 9151dc5377 | |||
| ca7c38a680 | |||
| f5bfd5a70c | |||
| f6c5f2f740 | |||
| 2de3427dc7 | |||
| 21703c81f4 | |||
| b79f22a63c | |||
| 59bd092a02 | |||
| 1d4ca6a6bc | |||
| b4c9719000 | |||
| 5c9edf3c1b | |||
| 2b21269900 | |||
| d57774df00 | |||
| 07a9685ae5 | |||
| cc06154d85 | |||
| fcde20903c | |||
| 570227c263 | |||
| 2572809998 | |||
| 16ff071365 | |||
| 293209098a | |||
| 76452fab99 | |||
| 8c947e639b | |||
| 96897a8425 | |||
| fa9ab92b40 | |||
| 6ea078c1cb | |||
| 3105e1e55b | |||
| 9c28982f7c | |||
| 0162c467f2 | |||
| d33544e26a | |||
| f03285883b | |||
| cd3ea68024 | |||
| 925b05f912 | |||
| cc528b640b | |||
| 1e42cec0ee | |||
| 6276d7e69b | |||
| 50947318e1 | |||
| fa7184e41b | |||
| b1713dc626 | |||
| a392b0d53f | |||
| 6093819c9c | |||
| 4413c80a2a | |||
| f50c5dda7b | |||
| b965e45684 | |||
| 9660e8a84d | |||
| e9edda8ef6 | |||
| b68d1410e4 | |||
| ab0ea2da43 | |||
| 74b37e4b78 | |||
| 70f14e1413 | |||
| 5c414f586b | |||
| b18ecc0a78 | |||
| 30c9ba9f18 | |||
| 246b706945 | |||
| de61a3c64c | |||
| a89852a331 | |||
| 7516bdfcaf | |||
| 7e5219e5ac | |||
| 2d3a9aebb4 | |||
| 7d0ed5d252 | |||
| 9cd7629615 | |||
| 4cd9dd6af7 | |||
| c2cbf5370d | |||
| c166f80b58 | |||
| 075127c56f | |||
| 34fcf0e54f | |||
| 7f4a1094f4 | |||
| 7846ddb3d7 | |||
| 6ff4f5ac67 | |||
| bf738d4b7f | |||
| 7242890d96 | |||
| b624cfd165 | |||
| bce1697c95 | |||
| 14462c4b2e | |||
| 3ae595ea1c | |||
| 4d76de1722 | |||
| 171ed7d2f6 | |||
| d9a97e268d | |||
| 365f2e6ee7 | |||
| 210993e146 | |||
| fe2bfb27cf | |||
| f9b756ecf8 | |||
| e8b2785329 | |||
| df7fc1e853 | |||
| 26824211aa | |||
| 0e13773d25 | |||
| 8d01ca3637 | |||
| 8b84a86f0c | |||
| be49cba34b | |||
| d7e049f187 | |||
| 4d7e8b2796 | |||
| 7f7f23d33d | |||
| 01888d8e32 | |||
| 223d29e5d9 | |||
| d58cb5b72c | |||
| 313f23fd65 | |||
| 54df471d0e | |||
| 72784c4943 | |||
| 7fa38aafe7 | |||
| fb7f16ecf2 | |||
| 8898c42f24 | |||
| 0054314355 | |||
| 28441d1e27 | |||
| 8d19529948 | |||
| 6838f3a6e9 | |||
| 8483f06595 | |||
| da15700205 | |||
| cd6236b1cd | |||
| 7b07b8256b |
+20
-19
@@ -3,29 +3,30 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
gomod:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/alpine"
|
||||
directories:
|
||||
- "/packaging/docker/alpine"
|
||||
- "/packaging/docker/distroless"
|
||||
- "/packaging/debian"
|
||||
- "/packaging/rpm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/distroless"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/debian"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/rpm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
docker:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -6,6 +6,14 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '.github/workflows/build_binaries.yml'
|
||||
- 'Makefile'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '**.go'
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
@@ -13,9 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
@@ -24,7 +32,8 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v6
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
|
||||
@@ -38,22 +38,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -11,19 +11,21 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
- '.github/workflows/debian_packages.yml'
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -38,13 +40,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -53,24 +55,24 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-packages:
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
- '.github/workflows/docker.yml'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
@@ -18,13 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -51,50 +52,48 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
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' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
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@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -14,11 +14,12 @@ on:
|
||||
- '.github/workflows/rpm_packages.yml'
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -30,24 +31,24 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-package:
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Close Stale Pull Requests
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 14
|
||||
stale-pr-label: stale
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs within 14 days.
|
||||
close-pr-message: >
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
Please feel free to reopen it if you would like to continue working on it.
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ When reporting bugs:
|
||||
### Requirements
|
||||
|
||||
- **Git**
|
||||
- **Go >= 1.24**
|
||||
- **Go >= 1.26**
|
||||
- **PostgreSQL**
|
||||
|
||||
### Getting Started
|
||||
@@ -103,7 +103,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
|
||||
### Cross-Platform Support
|
||||
|
||||
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5)
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
|
||||
- macOS (amd64, arm64)
|
||||
- FreeBSD, OpenBSD, Windows (amd64)
|
||||
|
||||
|
||||
@@ -16,12 +16,11 @@ export PGPASSWORD := postgres
|
||||
linux-armv7 \
|
||||
linux-armv6 \
|
||||
linux-armv5 \
|
||||
linux-x86 \
|
||||
linux-riscv64 \
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
openbsd-amd64 \
|
||||
netbsd-amd64 \
|
||||
build \
|
||||
run \
|
||||
clean \
|
||||
@@ -63,6 +62,10 @@ linux-armv5:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-riscv64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
@@ -79,7 +82,7 @@ openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 linux-riscv64 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
|
||||
|
||||
run:
|
||||
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
@@ -100,7 +103,7 @@ test:
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
gofmt -d -e .
|
||||
test -z "$$(gofmt -l .)"
|
||||
golangci-lint run
|
||||
|
||||
integration-test:
|
||||
@@ -113,6 +116,8 @@ integration-test:
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
LOG_LEVEL=debug \
|
||||
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
@@ -135,7 +140,7 @@ docker-image-distroless:
|
||||
|
||||
docker-images:
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
|
||||
--file packaging/docker/alpine/Dockerfile \
|
||||
--tag $(DOCKER_IMAGE):$(VERSION) \
|
||||
--push .
|
||||
@@ -162,3 +167,4 @@ debian-packages: clean
|
||||
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||
$(MAKE) debian DOCKER_PLATFORM=riscv64
|
||||
|
||||
@@ -103,7 +103,7 @@ Features
|
||||
- Compatible only with modern browsers.
|
||||
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
|
||||
- Provides official Debian/RPM packages and pre-built binaries.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM and RISC-V architecture support.
|
||||
- Uses a limited amount of third-party go dependencies
|
||||
- Has a comprehensive testsuite, with both unit tests and integration tests.
|
||||
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
|
||||
|
||||
+76
-3
@@ -22,6 +22,8 @@ type Client struct {
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
//
|
||||
//go:fix inline
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
}
|
||||
@@ -486,7 +488,7 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
// UpdateCategoryContext updates a category.
|
||||
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: SetOptionalField(title),
|
||||
Title: new(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -886,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()
|
||||
@@ -979,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()
|
||||
@@ -1077,14 +1146,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FlushHistory changes all entries with the status "read" to "removed".
|
||||
// FlushHistory deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistory() error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FlushHistoryContext(ctx)
|
||||
}
|
||||
|
||||
// FlushHistoryContext changes all entries with the status "read" to "removed".
|
||||
// FlushHistoryContext deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistoryContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
|
||||
return err
|
||||
@@ -1229,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))
|
||||
}
|
||||
}
|
||||
|
||||
+38
-7
@@ -10,9 +10,8 @@ import (
|
||||
|
||||
// Entry statuses.
|
||||
const (
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
EntryStatusRemoved = "removed"
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
)
|
||||
|
||||
// User represents a user in the system.
|
||||
@@ -75,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"`
|
||||
@@ -149,12 +146,15 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
NextCheckAt time.Time `json:"next_check_at"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
NoMediaPlayer bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
@@ -166,6 +166,7 @@ type Feed struct {
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
@@ -174,6 +175,14 @@ type Feed struct {
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
NtfyTopic string `json:"ntfy_topic"`
|
||||
PushoverEnabled bool `json:"pushover_enabled"`
|
||||
PushoverPriority int `json:"pushover_priority"`
|
||||
Icon *FeedIcon `json:"icon"`
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
@@ -185,7 +194,9 @@ type FeedCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
NoMediaPlayer bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
@@ -206,6 +217,7 @@ type FeedModificationRequest struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
UrlRewriteRules *string `json:"urlrewrite_rules"`
|
||||
@@ -214,12 +226,14 @@ type FeedModificationRequest struct {
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
NoMediaPlayer *bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
@@ -318,6 +332,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
Tags []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
@@ -327,6 +342,20 @@ type EntryResultSet struct {
|
||||
Entries Entries `json:"entries"`
|
||||
}
|
||||
|
||||
// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
|
||||
type EntryIDsFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Starred *bool
|
||||
Status string
|
||||
}
|
||||
|
||||
// EntryIDsResultSet represents the response when fetching entry ID lists.
|
||||
type EntryIDsResultSet struct {
|
||||
Total int `json:"total"`
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
}
|
||||
|
||||
// VersionResponse represents the version and the build information of the Miniflux instance.
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
@@ -357,6 +386,8 @@ type APIKeyCreationRequest struct {
|
||||
}
|
||||
|
||||
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
|
||||
//
|
||||
//go:fix inline
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
return new(value)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
db:
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.24
|
||||
// When changing version here don't forget to also upgrade CONTRIBUTING.md
|
||||
|
||||
// +heroku goVersion go1.26
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.8
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/term v0.40.0
|
||||
github.com/tdewolff/minify/v2 v2.24.13
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
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/tdewolff/parse/v2 v2.8.5 // 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.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
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/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.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
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.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=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
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=
|
||||
@@ -42,10 +42,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/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=
|
||||
@@ -60,12 +62,15 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE=
|
||||
github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
|
||||
github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
|
||||
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
|
||||
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
@@ -83,10 +88,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -101,10 +106,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -123,8 +128,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -134,8 +139,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -145,8 +150,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
+60
-78
@@ -5,92 +5,74 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/version"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
router *mux.Router
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
}
|
||||
|
||||
// Serve declares API routes for the application.
|
||||
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
handler := &handler{store, pool, router}
|
||||
|
||||
sr := router.PathPrefix("/v1").Subrouter()
|
||||
// NewHandler returns an http.Handler that handles API v1 calls.
|
||||
// The returned handler expects the base path to be stripped from the request URL.
|
||||
func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
|
||||
handler := &handler{store: store, pool: pool}
|
||||
middleware := newMiddleware(store)
|
||||
sr.Use(middleware.handleCORS)
|
||||
sr.Use(middleware.apiKeyAuth)
|
||||
sr.Use(middleware.basicAuth)
|
||||
sr.Methods(http.MethodOptions)
|
||||
sr.HandleFunc("/users", handler.createUser).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/users", handler.users).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/me", handler.currentUser).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories", handler.createCategory).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/categories", handler.getCategories).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}/feeds", handler.getCategoryFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}/refresh", handler.refreshCategory).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}/entries", handler.getCategoryEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/discover", handler.discoverSubscriptions).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/counters", handler.fetchCounters).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/feeds/{feedID}/icon", handler.getIconByFeedID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, &versionResponse{
|
||||
Version: version.Version,
|
||||
Commit: version.Commit,
|
||||
BuildDate: version.BuildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
Compiler: runtime.Compiler,
|
||||
Arch: runtime.GOARCH,
|
||||
OS: runtime.GOOS,
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/users", handler.createUserHandler)
|
||||
mux.HandleFunc("GET /v1/users", handler.usersHandler)
|
||||
mux.HandleFunc("GET /v1/users/{identifier}", handler.dispatchUserLookupHandler)
|
||||
mux.HandleFunc("PUT /v1/users/{userID}", handler.updateUserHandler)
|
||||
mux.HandleFunc("DELETE /v1/users/{userID}", handler.removeUserHandler)
|
||||
mux.HandleFunc("PUT /v1/users/{userID}/mark-all-as-read", handler.markUserAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/me", handler.currentUserHandler)
|
||||
mux.HandleFunc("POST /v1/categories", handler.createCategoryHandler)
|
||||
mux.HandleFunc("GET /v1/categories", handler.getCategoriesHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}", handler.updateCategoryHandler)
|
||||
mux.HandleFunc("DELETE /v1/categories/{categoryID}", handler.removeCategoryHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}/refresh", handler.refreshCategoryHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/entries", handler.getCategoryEntriesHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler)
|
||||
mux.HandleFunc("POST /v1/discover", handler.discoverSubscriptionsHandler)
|
||||
mux.HandleFunc("POST /v1/feeds", handler.createFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds", handler.getFeedsHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/counters", handler.fetchCountersHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/refresh", handler.refreshAllFeedsHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}/refresh", handler.refreshFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}", handler.getFeedHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}", handler.updateFeedHandler)
|
||||
mux.HandleFunc("DELETE /v1/feeds/{feedID}", handler.removeFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/icon", handler.getIconByFeedIDHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/export", handler.exportFeedsHandler)
|
||||
mux.HandleFunc("POST /v1/import", handler.importFeedsHandler)
|
||||
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.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)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/star", handler.toggleStarredHandler)
|
||||
mux.HandleFunc("POST /v1/entries/{entryID}/save", handler.saveEntryHandler)
|
||||
mux.HandleFunc("GET /v1/entries/{entryID}/fetch-content", handler.fetchContentHandler)
|
||||
mux.HandleFunc("PUT /v1/flush-history", handler.flushHistoryHandler)
|
||||
mux.HandleFunc("DELETE /v1/flush-history", handler.flushHistoryHandler)
|
||||
mux.HandleFunc("GET /v1/icons/{iconID}", handler.getIconByIconIDHandler)
|
||||
mux.HandleFunc("GET /v1/enclosures/{enclosureID}", handler.getEnclosureByIDHandler)
|
||||
mux.HandleFunc("PUT /v1/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler)
|
||||
mux.HandleFunc("GET /v1/integrations/status", handler.getIntegrationsStatusHandler)
|
||||
mux.HandleFunc("GET /v1/version", handler.versionHandler)
|
||||
mux.HandleFunc("POST /v1/api-keys", handler.createAPIKeyHandler)
|
||||
mux.HandleFunc("GET /v1/api-keys", handler.getAPIKeysHandler)
|
||||
mux.HandleFunc("DELETE /v1/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler)
|
||||
|
||||
return middleware.withCORSHeaders(middleware.validateAPIKeyAuth(middleware.validateBasicAuth(mux)))
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -609,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
ExternalFontHosts: new(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -638,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
ExternalFontHosts: new("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
@@ -662,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
CustomJS: new("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -691,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("invalid_theme"),
|
||||
Theme: new("invalid_theme"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -721,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
|
||||
@@ -1090,7 +1090,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("new title"),
|
||||
Title: new("new title"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1109,7 +1109,7 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(true),
|
||||
HideGlobally: new(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1128,14 +1128,14 @@ func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(false),
|
||||
HideGlobally: new(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
|
||||
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
@@ -1612,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
|
||||
FeedURL: new("https://example.org/feed.xml"),
|
||||
}
|
||||
|
||||
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
|
||||
@@ -1653,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
|
||||
FeedURL: new(testConfig.testFeedURL),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -1685,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
CategoryID: miniflux.SetOptionalField(int64(123456789)),
|
||||
CategoryID: new(int64(123456789)),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -2413,64 +2413,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotGetRemovedEntries(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedEntries.Total == 0 {
|
||||
t.Fatalf(`Expected at least one entry, got none`)
|
||||
}
|
||||
|
||||
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeedEntries.Total != feedEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2689,6 +2631,303 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
entryID := result.Entries[0].ID
|
||||
|
||||
// Star the entry without changing its status.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`Expected entry to be starred`)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusUnread {
|
||||
t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
|
||||
}
|
||||
|
||||
// Unstar the entry.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Starred {
|
||||
t.Fatalf(`Expected entry to no longer be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
// A new user should have no entries at all.
|
||||
result, err := regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.EntryIDs == nil {
|
||||
t.Fatal(`Entry IDs should not be nil`)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != 0 {
|
||||
t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
|
||||
}
|
||||
|
||||
// Subscribe to a feed so there are entries.
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allEntries, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(allEntries.Entries) == 0 {
|
||||
t.Fatal(`Expected feed to have entries`)
|
||||
}
|
||||
|
||||
// Without filters, all entries should be returned.
|
||||
result, err = regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != allEntries.Total {
|
||||
t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
|
||||
}
|
||||
|
||||
// Filter by status=unread: all entries should be unread initially.
|
||||
unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Mark one entry as read and verify status filter results update.
|
||||
firstEntryID := allEntries.Entries[0].ID
|
||||
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
if unreadResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
|
||||
}
|
||||
|
||||
for _, id := range unreadResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
|
||||
}
|
||||
|
||||
// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
|
||||
if allEntries.Total >= 2 {
|
||||
pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
|
||||
}
|
||||
|
||||
// offset=1 should skip the first entry.
|
||||
offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(offsetResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
|
||||
}
|
||||
|
||||
if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
|
||||
t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by starred=true: initially no starred entries.
|
||||
starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Star the first entry and verify it appears in starred results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 1 {
|
||||
t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
if starredResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
|
||||
}
|
||||
|
||||
// The read starred entry should appear when filtering by starred=true (read status does not affect it).
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// starred=false should exclude the starred entry.
|
||||
notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range notStarredResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination with offset past the single starred result should return 0 entries but total 1.
|
||||
pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedStarred.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedStarred.Total != 1 {
|
||||
t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
|
||||
}
|
||||
|
||||
// Unstarring the entry should remove it from starred=true results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 0 {
|
||||
t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
// Invalid starred value should return 400.
|
||||
_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
|
||||
if err == nil {
|
||||
t.Fatal(`Expected error for invalid status parameter, got nil`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2718,8 +2957,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
entryUpdateRequest := &miniflux.EntryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("New title"),
|
||||
Content: miniflux.SetOptionalField("New content"),
|
||||
Title: new("New title"),
|
||||
Content: new("New content"),
|
||||
}
|
||||
|
||||
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
|
||||
|
||||
@@ -9,56 +9,60 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) createAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var apiKeyCreationRequest model.APIKeyCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, apiKey)
|
||||
response.JSONCreated(w, r, apiKey)
|
||||
}
|
||||
|
||||
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeys, err := h.store.APIKeys(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.OK(w, r, apiKeys)
|
||||
response.JSON(w, r, apiKeys)
|
||||
}
|
||||
|
||||
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
|
||||
if apiKeyID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid API key ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
|
||||
if errors.Is(err, storage.ErrAPIKeyNotFound) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func TestNewHandlerHandlesOptionsRequests(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodOptions, "/v1/users", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusNoContent {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "X-Auth-Token, Authorization, Content-Type, Accept" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Headers header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Max-Age"); got != "3600" {
|
||||
t.Fatalf(`Unexpected Access-Control-Max-Age header, got %q`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler(t *testing.T) {
|
||||
h := &handler{}
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.versionHandler(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf(`Unexpected Content-Type header, got %q`, got)
|
||||
}
|
||||
|
||||
var responseBody versionResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {
|
||||
t.Fatalf("Unexpected JSON decoding error: %v", err)
|
||||
}
|
||||
|
||||
if responseBody.Version != version.Version {
|
||||
t.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)
|
||||
}
|
||||
|
||||
if responseBody.Commit != version.Commit {
|
||||
t.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)
|
||||
}
|
||||
|
||||
if responseBody.BuildDate != version.BuildDate {
|
||||
t.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)
|
||||
}
|
||||
|
||||
if responseBody.GoVersion != runtime.Version() {
|
||||
t.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())
|
||||
}
|
||||
|
||||
if responseBody.Compiler != runtime.Compiler {
|
||||
t.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)
|
||||
}
|
||||
|
||||
if responseBody.Arch != runtime.GOARCH {
|
||||
t.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)
|
||||
}
|
||||
|
||||
if responseBody.OS != runtime.GOOS {
|
||||
t.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
prefix string
|
||||
path string
|
||||
}{
|
||||
{name: "empty base path", prefix: "", path: "/v1/users"},
|
||||
{name: "non empty base path", prefix: "/base", path: "/base/v1/users"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
handler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))
|
||||
|
||||
r := httptest.NewRequest(http.MethodOptions, scenario.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusNoContent {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,149 +5,179 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var categoryCreationRequest model.CategoryCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, category)
|
||||
response.JSONCreated(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var categoryModificationRequest model.CategoryModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
categoryModificationRequest.Patch(category)
|
||||
|
||||
if err := h.store.UpdateCategory(category); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, category)
|
||||
response.JSONCreated(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
|
||||
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" {
|
||||
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
|
||||
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))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.OK(w, r, categories)
|
||||
response.JSON(w, r, categories)
|
||||
}
|
||||
|
||||
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
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 {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,5 +190,5 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go h.pool.Push(jobs)
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -5,76 +5,73 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
if enclosureID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
response.JSON(w, r, enclosure)
|
||||
}
|
||||
|
||||
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
if enclosureID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var enclosureUpdateRequest model.EnclosureUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
|
||||
entry, err := b.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
h.findEntries(w, r, feedID, 0)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
h.findEntries(w, r, 0, categoryID)
|
||||
}
|
||||
|
||||
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
|
||||
h.findEntries(w, r, 0, 0)
|
||||
}
|
||||
|
||||
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
|
||||
statuses := request.QueryStringParamList(r, "status")
|
||||
for _, status := range statuses {
|
||||
if err := validator.ValidateEntryStatus(status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
|
||||
if err := validator.ValidateEntryOrder(order); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
|
||||
if err := validator.ValidateDirection(direction); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.QueryIntParam(r, "limit", 100)
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
if err := validator.ValidateRange(offset, limit); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
|
||||
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.BadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
feedID = request.QueryInt64Param(r, "feed_id", feedID)
|
||||
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
|
||||
json.BadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
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.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
|
||||
if globallyVisible {
|
||||
builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if !h.store.HasSaveEntry(request.UserID(r)) {
|
||||
json.BadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go integration.SendEntry(entry, settings)
|
||||
|
||||
json.Accepted(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
var entryUpdateRequest model.EntryUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
loggedUserID := request.UserID(r)
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if entryUpdateRequest.Content != nil {
|
||||
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
entryUpdateRequest.Content = &sanitizedContent
|
||||
}
|
||||
|
||||
entryUpdateRequest.Patch(entry)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if feedID <= 0 {
|
||||
json.BadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.BadRequest(w, r, errors.New("feed does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var importRequest entryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.URL == "" {
|
||||
json.BadRequest(w, r, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Status == "" {
|
||||
importRequest.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := model.NewEntry()
|
||||
entry.URL = importRequest.URL
|
||||
entry.CommentsURL = importRequest.CommentsURL
|
||||
entry.Author = importRequest.Author
|
||||
entry.Tags = importRequest.Tags
|
||||
|
||||
if importRequest.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
|
||||
} else {
|
||||
entry.Date = time.Now().UTC()
|
||||
}
|
||||
|
||||
if importRequest.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
} else {
|
||||
entry.Title = importRequest.Title
|
||||
}
|
||||
|
||||
hashInput := importRequest.ExternalID
|
||||
if hashInput == "" {
|
||||
hashInput = importRequest.URL
|
||||
}
|
||||
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
}
|
||||
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Status = importRequest.Status
|
||||
|
||||
if importRequest.Starred {
|
||||
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Starred = true
|
||||
}
|
||||
|
||||
if created {
|
||||
json.Created(w, r, entryIDResponse{ID: entry.ID})
|
||||
} else {
|
||||
json.OK(w, r, entryIDResponse{ID: entry.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
|
||||
if shouldUpdateContent {
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
json.OK(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
go h.store.FlushHistory(loggedUserID)
|
||||
json.Accepted(w, r)
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
|
||||
builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
|
||||
builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
|
||||
if err == nil {
|
||||
builder.WithStarred(starred)
|
||||
}
|
||||
}
|
||||
|
||||
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
|
||||
builder.WithSearchQuery(searchQuery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
|
||||
entry, err := b.GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)
|
||||
entry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
response.JSON(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithFeedID(feedID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithCategoryID(categoryID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
h.findEntries(w, r, feedID, 0)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
h.findEntries(w, r, 0, categoryID)
|
||||
}
|
||||
|
||||
func (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
h.findEntries(w, r, 0, 0)
|
||||
}
|
||||
|
||||
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
|
||||
statuses := request.QueryStringParamList(r, "status")
|
||||
for _, status := range statuses {
|
||||
if err := validator.ValidateEntryStatus(status); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
|
||||
if err := validator.ValidateEntryOrder(order); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
|
||||
if err := validator.ValidateDirection(direction); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.QueryIntParam(r, "limit", 100)
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
if err := validator.ValidateRange(offset, limit); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
|
||||
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
feedID = request.QueryInt64Param(r, "feed_id", feedID)
|
||||
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
tags := request.QueryStringParamList(r, "tags")
|
||||
|
||||
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 = builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
builder = configureFilters(builder, r)
|
||||
|
||||
entries, count, err := builder.GetEntriesWithCount()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
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) 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.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(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)
|
||||
}
|
||||
|
||||
func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.HasSaveEntry(request.UserID(r)) {
|
||||
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(request.UserID(r))
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go integration.SendEntry(entry, settings)
|
||||
|
||||
response.JSONAccepted(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entryUpdateRequest model.EntryUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
loggedUserID := request.UserID(r)
|
||||
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if entryUpdateRequest.Content != nil {
|
||||
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
entryUpdateRequest.Content = &sanitizedContent
|
||||
}
|
||||
|
||||
entryUpdateRequest.Patch(entry)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID <= 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
response.JSONBadRequest(w, r, errors.New("feed does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var importRequest entryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.URL == "" {
|
||||
response.JSONBadRequest(w, r, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Status == "" {
|
||||
importRequest.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := model.NewEntry()
|
||||
entry.URL = importRequest.URL
|
||||
entry.CommentsURL = importRequest.CommentsURL
|
||||
entry.Author = importRequest.Author
|
||||
entry.Tags = importRequest.Tags
|
||||
|
||||
if importRequest.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
|
||||
} else {
|
||||
entry.Date = time.Now().UTC()
|
||||
}
|
||||
|
||||
if importRequest.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
} else {
|
||||
entry.Title = importRequest.Title
|
||||
}
|
||||
|
||||
hashInput := importRequest.ExternalID
|
||||
if hashInput == "" {
|
||||
hashInput = importRequest.URL
|
||||
}
|
||||
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
}
|
||||
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
|
||||
if errors.Is(err, storage.ErrEntryTombstoned) {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Status = importRequest.Status
|
||||
|
||||
if importRequest.Starred {
|
||||
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Starred = true
|
||||
}
|
||||
|
||||
if created {
|
||||
response.JSONCreated(w, r, entryIDResponse{ID: entry.ID})
|
||||
} else {
|
||||
response.JSON(w, r, entryIDResponse{ID: entry.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
|
||||
WithFeedID(entry.FeedID).
|
||||
GetFeed()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
|
||||
if shouldUpdateContent {
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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) *storage.EntryQueryBuilder {
|
||||
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
|
||||
builder = builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
|
||||
builder = builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
|
||||
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
|
||||
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 = builder.WithStarred(starred)
|
||||
}
|
||||
}
|
||||
|
||||
if searchQuery := request.QueryStringParam(r, "search", ""); 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
|
||||
}
|
||||
@@ -5,24 +5,25 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
feedHandler "miniflux.app/v2/internal/reader/handler"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var feedCreationRequest model.FeedCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,57 +31,60 @@ func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
|
||||
if feedCreationRequest.CategoryID == 0 {
|
||||
category, err := h.store.FirstCategory(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
feedCreationRequest.CategoryID = category.ID
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
feed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, &feedCreationResponse{FeedID: feed.ID})
|
||||
response.JSONCreated(w, r, &feedCreationResponse{FeedID: feed.ID})
|
||||
}
|
||||
|
||||
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
localizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) refreshAllFeeds(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 {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,141 +96,164 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go h.pool.Push(jobs)
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var feedModificationRequest model.FeedModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
originalFeed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if originalFeed == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
originalFeed.ResetErrorCounter()
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed, err = h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, originalFeed)
|
||||
response.JSONCreated(w, r, originalFeed)
|
||||
}
|
||||
|
||||
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
response.JSON(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.store.Feeds(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
response.JSON(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) fetchCounters(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
counters, err := h.store.FetchCounters(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, counters)
|
||||
response.JSON(w, r, counters)
|
||||
}
|
||||
|
||||
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.store.FeedByID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feed)
|
||||
response.JSON(w, r, feed)
|
||||
}
|
||||
|
||||
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveFeed(userID, feedID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -4,48 +4,57 @@
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &feedIconResponse{
|
||||
response.JSON(w, r, &feedIconResponse{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) getIconByIconID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
iconID := request.RouteInt64Param(r, "iconID")
|
||||
if iconID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid icon ID"))
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByID(iconID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &feedIconResponse{
|
||||
response.JSON(w, r, &feedIconResponse{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
@@ -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"`
|
||||
+12
-12
@@ -9,7 +9,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
@@ -20,21 +20,21 @@ type middleware struct {
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
func (m *middleware) handleCORS(next http.Handler) http.Handler {
|
||||
func (m *middleware) withCORSHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, Authorization, Content-Type, Accept")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response.NoContent(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
func (m *middleware) validateAPIKeyAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
token := r.Header.Get("X-Auth-Token")
|
||||
@@ -51,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
|
||||
user, err := m.store.UserByAPIKey(token)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
func (m *middleware) validateBasicAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if request.IsAuthenticated(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -105,7 +105,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,13 +128,13 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,30 +7,29 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response/xml"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/reader/opml"
|
||||
)
|
||||
|
||||
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) exportFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(h.store)
|
||||
opmlExport, err := opmlHandler.Export(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
xml.OK(w, r, opmlExport)
|
||||
response.XML(w, r, opmlExport)
|
||||
}
|
||||
|
||||
func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) importFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(h.store)
|
||||
err := opmlHandler.Import(request.UserID(r), r.Body)
|
||||
defer r.Body.Close()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
response.JSONCreated(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
@@ -17,15 +17,15 @@ import (
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
|
||||
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,
|
||||
@@ -56,14 +56,14 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(subscriptions) == 0 {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, subscriptions)
|
||||
response.JSON(w, r, subscriptions)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var userCreationRequest model.UserCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.CreateUser(&userCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
|
||||
var userModificationRequest model.UserModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if originalUser == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !request.IsAdminUser(r) {
|
||||
if originalUser.ID != request.UserID(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
|
||||
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, originalUser)
|
||||
}
|
||||
|
||||
func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID != request.UserID(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkAllAsRead(userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
json.OK(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.store.Users()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
users.UseTimezone(request.UserTimezone(r))
|
||||
json.OK(w, r, users)
|
||||
}
|
||||
|
||||
func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user.UseTimezone(request.UserTimezone(r))
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == request.UserID(r) {
|
||||
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
h.store.RemoveUserAsync(user.ID)
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var userCreationRequest model.UserCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.CreateUser(&userCreationRequest)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var userModificationRequest model.UserModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if originalUser == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !request.IsAdminUser(r) {
|
||||
if originalUser.ID != request.UserID(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
|
||||
response.JSONBadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, originalUser)
|
||||
}
|
||||
|
||||
func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if userID != request.UserID(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkAllAsRead(userID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response.JSON(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
|
||||
}
|
||||
|
||||
func (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.store.Users()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
users.UseTimezone(request.UserTimezone(r))
|
||||
response.JSON(w, r, users)
|
||||
}
|
||||
|
||||
func (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := request.RouteStringParam(r, "identifier")
|
||||
userID := request.RouteInt64Param(r, "identifier")
|
||||
if userID > 0 {
|
||||
r.SetPathValue("userID", identifier)
|
||||
h.userByIDHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.SetPathValue("username", identifier)
|
||||
h.userByUsernameHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user.UseTimezone(request.UserTimezone(r))
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == request.UserID(r) {
|
||||
response.JSONBadRequest(w, r, errors.New("you cannot remove yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.store.RemoveUser(user.ID); err != nil {
|
||||
slog.Error("Unable to delete user",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, r, &versionResponse{
|
||||
Version: version.Version,
|
||||
Commit: version.Commit,
|
||||
BuildDate: version.BuildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
Compiler: runtime.Compiler,
|
||||
Arch: runtime.GOARCH,
|
||||
OS: runtime.GOOS,
|
||||
})
|
||||
}
|
||||
@@ -23,13 +23,27 @@ func askCredentials() (string, string) {
|
||||
fmt.Print("Enter Username: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, _ := reader.ReadString('\n')
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
printfAndExit("unable to read username: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
|
||||
state, _ := term.GetState(fd)
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
state, err := term.GetState(fd)
|
||||
if err != nil {
|
||||
printfAndExit("unable to get terminal state: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if restoreErr := term.Restore(fd, state); restoreErr != nil {
|
||||
printfAndExit("unable to restore terminal state: %w", restoreErr)
|
||||
}
|
||||
}()
|
||||
|
||||
bytePassword, err := term.ReadPassword(fd)
|
||||
if err != nil {
|
||||
printfAndExit("unable to read password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("application_sessions_removed", nbSessions),
|
||||
slog.Int64("user_sessions_removed", nbUserSessions),
|
||||
)
|
||||
if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
|
||||
slog.Error("Unable to clean old web sessions", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("web_sessions_removed", nbWebSessions),
|
||||
)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
@@ -47,17 +48,11 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
|
||||
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
|
||||
}
|
||||
|
||||
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Clearing content from removed entries completed",
|
||||
slog.Int64("removed_entries_content_cleared", contentAffected))
|
||||
slog.Info("Orphan icons cleanup completed",
|
||||
slog.Int64("orphan_icons_removed", nbIcons),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-38
@@ -4,7 +4,6 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -92,27 +91,8 @@ func Parse() {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
|
||||
if oauth2Provider != "oidc" && oauth2Provider != "google" {
|
||||
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.DisableLocalAuth() {
|
||||
switch {
|
||||
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
|
||||
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
|
||||
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.AuthProxyHeader() != "" {
|
||||
if len(config.Opts.TrustedReverseProxyNetworks()) == 0 {
|
||||
printErrorAndExit(errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used"))
|
||||
}
|
||||
if err := config.Opts.Validate(); err != nil {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
@@ -120,6 +100,16 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
}
|
||||
|
||||
if flagVersion {
|
||||
fmt.Println(version.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if flagDebugMode {
|
||||
config.Opts.SetLogLevel("debug")
|
||||
}
|
||||
@@ -134,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()
|
||||
}
|
||||
@@ -148,30 +138,20 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
}
|
||||
|
||||
if flagVersion {
|
||||
fmt.Println(version.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if config.Opts.IsDefaultDatabaseURL() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -181,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()
|
||||
|
||||
@@ -251,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,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)
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ func startDaemon(store *storage.Storage) {
|
||||
httpServers = server.StartWebServer(store, pool)
|
||||
}
|
||||
|
||||
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
|
||||
go collector.GatherStorageMetrics()
|
||||
go collector.GatherStorageMetrics(metricsCtx)
|
||||
}
|
||||
|
||||
if systemd.HasNotifySocket() {
|
||||
@@ -75,6 +76,7 @@ func startDaemon(store *storage.Storage) {
|
||||
|
||||
<-stop
|
||||
slog.Debug("Shutting down the process")
|
||||
cancelMetrics()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -92,5 +94,9 @@ func startDaemon(store *storage.Storage) {
|
||||
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()))
|
||||
|
||||
+15
-12
@@ -219,6 +219,11 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"FETCH_BILIBILI_WATCH_TIME": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
@@ -293,7 +298,7 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"ICON_FETCH_ALLOW_PRIVATE_NETWORKS": {
|
||||
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
@@ -353,11 +358,6 @@ func NewConfigOptions() *configOptions {
|
||||
rawValue: "",
|
||||
valueType: urlType,
|
||||
},
|
||||
"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS": {
|
||||
parsedBoolValue: false,
|
||||
rawValue: "0",
|
||||
valueType: boolType,
|
||||
},
|
||||
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
|
||||
parsedDuration: 120 * time.Second,
|
||||
rawValue: "120",
|
||||
@@ -791,8 +791,15 @@ func (c *configOptions) HTTPS() bool {
|
||||
return c.options["HTTPS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) IconFetchAllowPrivateNetworks() bool {
|
||||
return c.options["ICON_FETCH_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
|
||||
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) InvidiousInstance() string {
|
||||
@@ -847,10 +854,6 @@ func (c *configOptions) MediaCustomProxyURL() *url.URL {
|
||||
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
|
||||
}
|
||||
|
||||
func (c *configOptions) MediaProxyAllowPrivateNetworks() bool {
|
||||
return c.options["MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
|
||||
}
|
||||
|
||||
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
|
||||
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
|
||||
}
|
||||
|
||||
@@ -1351,27 +1351,51 @@ func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconFetchAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
if !configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"ICON_FETCH_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.IconFetchAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected ICON_FETCH_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
if configParser.options.FetcherAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.IntegrationAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,30 +1466,6 @@ func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProxyAllowPrivateNetworksOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
if configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled by default")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be enabled")
|
||||
}
|
||||
|
||||
if err := configParser.parseLines([]string{"MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if configParser.options.MediaProxyAllowPrivateNetworks() {
|
||||
t.Fatalf("Expected MEDIA_PROXY_ALLOW_PRIVATE_NETWORKS to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
|
||||
@@ -1752,3 +1752,294 @@ func TestConfigMapWithRedactedSecrets(t *testing.T) {
|
||||
t.Fatalf("Expected ADMIN_PASSWORD value to be redacted, got '%s'", configMap[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOIDCProviderRequiresDiscoveryEndpoint(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
err := configParser.options.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when OIDC provider is set without discovery endpoint")
|
||||
}
|
||||
if err.Error() != "OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider" {
|
||||
t.Fatalf("Unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOIDCProviderWithDiscoveryEndpoint(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"OAUTH2_PROVIDER=oidc",
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDisableLocalAuthWithoutAlternative(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when local auth is disabled without alternative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDisableLocalAuthWithOAuth2ButNoUserCreation(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DISABLE_LOCAL_AUTH=1",
|
||||
"OAUTH2_PROVIDER=oidc",
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDisableLocalAuthWithOAuth2AndUserCreation(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DISABLE_LOCAL_AUTH=1",
|
||||
"OAUTH2_PROVIDER=oidc",
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
|
||||
"OAUTH2_USER_CREATION=1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDisableLocalAuthWithAuthProxyButNoUserCreation(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DISABLE_LOCAL_AUTH=1",
|
||||
"AUTH_PROXY_HEADER=X-Forwarded-User",
|
||||
"AUTH_PROXY_USER_CREATION=0",
|
||||
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDisableLocalAuthWithAuthProxyAndUserCreation(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DISABLE_LOCAL_AUTH=1",
|
||||
"AUTH_PROXY_HEADER=X-Forwarded-User",
|
||||
"AUTH_PROXY_USER_CREATION=1",
|
||||
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAuthProxyRequiresTrustedNetworks(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
err := configParser.options.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when auth proxy header is set without trusted networks")
|
||||
}
|
||||
if err.Error() != "TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used" {
|
||||
t.Fatalf("Unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAuthProxyWithTrustedNetworks(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"AUTH_PROXY_HEADER=X-Forwarded-User",
|
||||
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertFileMissingKeyFile(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when CERT_FILE is set without KEY_FILE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeyFileMissingCertFile(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when KEY_FILE is set without CERT_FILE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertFileAndKeyFile(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"CERT_FILE=/path/to/cert.pem",
|
||||
"KEY_FILE=/path/to/key.pem",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertDomainAndCertFileMutuallyExclusive(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"CERT_DOMAIN=example.com",
|
||||
"CERT_FILE=/path/to/cert.pem",
|
||||
"KEY_FILE=/path/to/key.pem",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when both CERT_DOMAIN and CERT_FILE are set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertDomainAlone(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMetricsUsernameWithoutPassword(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"METRICS_USERNAME=admin"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when METRICS_USERNAME is set without METRICS_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMetricsPasswordWithoutUsername(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret"}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when METRICS_PASSWORD is set without METRICS_USERNAME")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMetricsUsernameAndPassword(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"METRICS_USERNAME=admin",
|
||||
"METRICS_PASSWORD=secret",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabaseMinConnsGreaterThanMaxConns(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DATABASE_MIN_CONNS=25",
|
||||
"DATABASE_MAX_CONNS=10",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when DATABASE_MIN_CONNS > DATABASE_MAX_CONNS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabaseMinConnsEqualToMaxConns(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"DATABASE_MIN_CONNS=10",
|
||||
"DATABASE_MAX_CONNS=10",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchedulerRoundRobinMinGreaterThanMax(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=1440",
|
||||
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when SCHEDULER_ROUND_ROBIN_MIN_INTERVAL > SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchedulerRoundRobinMinLessThanMax(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=60",
|
||||
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=1440",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchedulerEntryFrequencyMinGreaterThanMax(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=1440",
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=5",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL > SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchedulerEntryFrequencyMinLessThanMax(t *testing.T) {
|
||||
configParser := NewConfigParser()
|
||||
if err := configParser.parseLines([]string{
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=5",
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=1440",
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,49 @@ func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
// Validate checks for invalid or incomplete option combinations.
|
||||
func (c *configOptions) Validate() error {
|
||||
if c.OAuth2Provider() == "oidc" && c.OAuth2OIDCDiscoveryEndpoint() == "" {
|
||||
return errors.New("OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider")
|
||||
}
|
||||
|
||||
if c.DisableLocalAuth() {
|
||||
if c.OAuth2Provider() == "" && c.AuthProxyHeader() == "" {
|
||||
return errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is set. Please enable at least one authentication source")
|
||||
}
|
||||
}
|
||||
|
||||
if c.AuthProxyHeader() != "" && len(c.TrustedReverseProxyNetworks()) == 0 {
|
||||
return errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used")
|
||||
}
|
||||
|
||||
if (c.CertFile() != "") != (c.CertKeyFile() != "") {
|
||||
return errors.New("CERT_FILE and KEY_FILE must both be provided")
|
||||
}
|
||||
|
||||
if c.CertDomain() != "" && c.CertFile() != "" {
|
||||
return errors.New("CERT_DOMAIN and CERT_FILE/KEY_FILE are mutually exclusive")
|
||||
}
|
||||
|
||||
if (c.MetricsUsername() != "") != (c.MetricsPassword() != "") {
|
||||
return errors.New("METRICS_USERNAME and METRICS_PASSWORD must both be provided")
|
||||
}
|
||||
|
||||
if c.DatabaseMinConns() > c.DatabaseMaxConns() {
|
||||
return errors.New("DATABASE_MIN_CONNS must be less than or equal to DATABASE_MAX_CONNS")
|
||||
}
|
||||
|
||||
if c.SchedulerRoundRobinMinInterval() > c.SchedulerRoundRobinMaxInterval() {
|
||||
return errors.New("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL must be less than or equal to SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
|
||||
}
|
||||
|
||||
if c.SchedulerEntryFrequencyMinInterval() > c.SchedulerEntryFrequencyMaxInterval() {
|
||||
return errors.New("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL must be less than or equal to SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].parsedStringValue
|
||||
|
||||
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
)
|
||||
@@ -352,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
|
||||
},
|
||||
@@ -483,7 +488,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
@@ -723,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
|
||||
}
|
||||
@@ -1081,7 +1091,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
var id int64
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
@@ -1427,4 +1437,112 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
|
||||
CREATE TABLE web_sessions (
|
||||
id text not null,
|
||||
secret_hash bytea not null,
|
||||
user_id int references users(id) on delete cascade,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
user_agent text not null default '',
|
||||
ip inet,
|
||||
state jsonb not null default '{}'::jsonb,
|
||||
primary key (id),
|
||||
check (jsonb_typeof(state) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX web_sessions_user_id_idx
|
||||
ON web_sessions (user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX web_sessions_created_at_idx
|
||||
ON web_sessions (created_at);
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE entry_tombstones (
|
||||
feed_id bigint not null references feeds(id) on delete cascade,
|
||||
hash text not null check (hash <> ''),
|
||||
deleted_at timestamp with time zone not null default now(),
|
||||
primary key (feed_id, hash)
|
||||
);
|
||||
|
||||
CREATE INDEX entry_tombstones_deleted_at_idx
|
||||
ON entry_tombstones (deleted_at);
|
||||
|
||||
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
|
||||
SELECT feed_id, hash, changed_at
|
||||
FROM entries
|
||||
WHERE status = 'removed' AND hash <> ''
|
||||
ON CONFLICT (feed_id, hash) DO NOTHING;
|
||||
|
||||
DELETE FROM entries WHERE status = 'removed';
|
||||
|
||||
-- The "removed" status is no longer used, so drop the partial
|
||||
-- predicate so the planner can use the index for every search.
|
||||
DROP INDEX document_vectors_idx;
|
||||
CREATE INDEX document_vectors_idx
|
||||
ON entries
|
||||
USING gin(document_vectors);
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM integrations WHERE user_id NOT IN (SELECT id FROM users);
|
||||
|
||||
ALTER TABLE integrations
|
||||
ADD CONSTRAINT integrations_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// backup_eligible is nullable: NULL marks pre-migration rows so the login path can backfill it from the assertion on first use.
|
||||
_, err = tx.Exec(`
|
||||
UPDATE webauthn_credentials SET name = '' WHERE name IS NULL;
|
||||
|
||||
ALTER TABLE webauthn_credentials
|
||||
ALTER COLUMN name SET DEFAULT '',
|
||||
ALTER COLUMN name SET NOT NULL,
|
||||
ADD COLUMN backup_eligible boolean,
|
||||
ADD COLUMN backup_state boolean NOT NULL DEFAULT false;
|
||||
`)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
# Miniflux Fever API
|
||||
|
||||
This document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Path: `BASE_URL/fever/`
|
||||
- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`
|
||||
- Response format: JSON only
|
||||
- Reported API version: `3`
|
||||
|
||||
## Authentication
|
||||
|
||||
Fever authentication is enabled per user from the Miniflux integrations page.
|
||||
|
||||
- `Fever Username` and `Fever Password` are configured in Miniflux
|
||||
- Miniflux stores the Fever token as the MD5 hash of `username:password`
|
||||
- Clients authenticate by sending that token as the `api_key` parameter
|
||||
- Token lookup is case-insensitive
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
api_key = md5("fever_username:fever_password")
|
||||
```
|
||||
|
||||
Example shell command:
|
||||
|
||||
```bash
|
||||
printf '%s' 'fever_username:fever_password' | md5sum
|
||||
```
|
||||
|
||||
Authentication failure does not return HTTP 401. The middleware returns HTTP 200 with:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 0
|
||||
}
|
||||
```
|
||||
|
||||
On successful authentication, every response includes:
|
||||
|
||||
- `api_version`: always `3`
|
||||
- `auth`: always `1`
|
||||
- `last_refreshed_on_time`: current server Unix timestamp at response time
|
||||
|
||||
## Dispatch Rules
|
||||
|
||||
The handler selects the first matching operation in this order:
|
||||
|
||||
1. `groups`
|
||||
2. `feeds`
|
||||
3. `favicons`
|
||||
4. `unread_item_ids`
|
||||
5. `saved_item_ids`
|
||||
6. `items`
|
||||
7. `mark=item`
|
||||
8. `mark=feed`
|
||||
9. `mark=group`
|
||||
|
||||
If no selector is provided, the server returns the base authenticated response only.
|
||||
|
||||
For read operations, the selector must be present in the query string. For write operations, `mark`, `as`, `id`, and `before` are read from request form values, so they may come from the query string or a form body.
|
||||
|
||||
## Read Operations
|
||||
|
||||
### `?groups`
|
||||
|
||||
Returns:
|
||||
|
||||
- `groups`: list of categories
|
||||
- `feeds_groups`: mapping of category IDs to feed IDs
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "All"
|
||||
}
|
||||
],
|
||||
"feeds_groups": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"feed_ids": "10,11"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `groups` are Miniflux categories
|
||||
- `feeds_groups.feed_ids` is a comma-separated string
|
||||
- categories with no feeds are returned in `groups` but have no `feeds_groups` entry
|
||||
|
||||
### `?feeds`
|
||||
|
||||
Returns:
|
||||
|
||||
- `feeds`: list of feeds
|
||||
- `feeds_groups`: mapping of category IDs to feed IDs
|
||||
|
||||
Feed fields:
|
||||
|
||||
- `id`
|
||||
- `favicon_id`
|
||||
- `title`
|
||||
- `url`
|
||||
- `site_url`
|
||||
- `is_spark`
|
||||
- `last_updated_on_time`
|
||||
|
||||
Notes:
|
||||
|
||||
- `favicon_id` is `0` when the feed has no icon
|
||||
- `is_spark` is always `0` in this implementation
|
||||
- `last_updated_on_time` is the feed check time as a Unix timestamp
|
||||
|
||||
### `?favicons`
|
||||
|
||||
Returns:
|
||||
|
||||
- `favicons`: list of favicon objects
|
||||
|
||||
Favicon fields:
|
||||
|
||||
- `id`
|
||||
- `data`
|
||||
|
||||
Notes:
|
||||
|
||||
- `data` is a data URL such as `image/png;base64,...`
|
||||
|
||||
### `?unread_item_ids`
|
||||
|
||||
Returns:
|
||||
|
||||
- `unread_item_ids`: comma-separated list of unread entry IDs
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"unread_item_ids": "100,101,102"
|
||||
}
|
||||
```
|
||||
|
||||
### `?saved_item_ids`
|
||||
|
||||
Returns:
|
||||
|
||||
- `saved_item_ids`: comma-separated list of starred entry IDs
|
||||
|
||||
### `?items`
|
||||
|
||||
Returns:
|
||||
|
||||
- `items`: list of entries
|
||||
- `total_items`: total number of non-removed entries for the user
|
||||
|
||||
Item fields:
|
||||
|
||||
- `id`
|
||||
- `feed_id`
|
||||
- `title`
|
||||
- `author`
|
||||
- `html`
|
||||
- `url`
|
||||
- `is_saved`
|
||||
- `is_read`
|
||||
- `created_on_time`
|
||||
|
||||
The implementation always excludes entries whose status is `removed`.
|
||||
|
||||
#### Pagination and filtering
|
||||
|
||||
The handler applies a fixed limit of 50 items.
|
||||
|
||||
Supported parameters:
|
||||
|
||||
- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`
|
||||
- `max_id`: when equal to `0`, returns the most recent entries ordered by `id DESC`; when greater than `0`, returns entries with `id < max_id`, ordered by `id DESC`
|
||||
- `with_ids`: comma-separated list of entry IDs to fetch
|
||||
|
||||
Selector precedence inside `?items` is:
|
||||
|
||||
1. `since_id`
|
||||
2. `max_id`
|
||||
3. `with_ids`
|
||||
4. no item filter
|
||||
|
||||
Notes:
|
||||
|
||||
- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation
|
||||
- invalid `with_ids` members are parsed as `0` and do not match normal entries
|
||||
- when `items` is requested without `since_id`, `max_id`, or `with_ids`, the code applies no explicit `ORDER BY`, so result ordering is not guaranteed by SQL
|
||||
- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"total_items": 245,
|
||||
"items": [
|
||||
{
|
||||
"id": 100,
|
||||
"feed_id": 10,
|
||||
"title": "Example entry",
|
||||
"author": "Author",
|
||||
"html": "<p>Content</p>",
|
||||
"url": "https://example.org/post",
|
||||
"is_saved": 0,
|
||||
"is_read": 1,
|
||||
"created_on_time": 1709990000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Write Operations
|
||||
|
||||
Normal successful write operations return the base authenticated response:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000
|
||||
}
|
||||
```
|
||||
|
||||
### `mark=item`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=item`
|
||||
- `id=<entry_id>`
|
||||
- `as=read|unread|saved|unsaved`
|
||||
|
||||
Behavior:
|
||||
|
||||
- `as=read`: marks the entry as read
|
||||
- `as=unread`: marks the entry as unread
|
||||
- `as=saved`: toggles the starred flag
|
||||
- `as=unsaved`: toggles the starred flag
|
||||
|
||||
Important:
|
||||
|
||||
- `saved` and `unsaved` both call the same toggle operation
|
||||
- sending `as=saved` twice will save, then unsave
|
||||
- sending `as=unsaved` twice will unsave, then save
|
||||
- if `id <= 0`, the handler returns without writing a response body
|
||||
- if the entry does not exist or is already removed, the server returns the base response without an error
|
||||
|
||||
### `mark=feed`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=feed`
|
||||
- `as=read`
|
||||
- `id=<feed_id>`
|
||||
- `before=<unix_timestamp>`
|
||||
|
||||
Behavior:
|
||||
|
||||
- marks unread entries in the feed as read when `published_at < before`
|
||||
- the update runs asynchronously in a goroutine after the response is returned
|
||||
|
||||
Notes:
|
||||
|
||||
- if `id <= 0`, the handler returns without writing a response body
|
||||
- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read
|
||||
|
||||
### `mark=group`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=<group_id>`
|
||||
- `before=<unix_timestamp>`
|
||||
|
||||
Behavior:
|
||||
|
||||
- `id=0`: marks all unread entries as read, ignoring `before`
|
||||
- `id>0`: marks unread entries in the matching category as read when `published_at < before`
|
||||
- the update runs asynchronously in a goroutine after the response is returned
|
||||
|
||||
Notes:
|
||||
|
||||
- group IDs map to Miniflux category IDs
|
||||
- if `id < 0`, the handler returns without writing a response body
|
||||
- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication failures:
|
||||
|
||||
- HTTP status: `200`
|
||||
- body: `{"api_version":3,"auth":0}`
|
||||
|
||||
Internal errors:
|
||||
|
||||
- HTTP status: `500`
|
||||
- body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Differences From Generic Fever Documentation
|
||||
|
||||
This implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.
|
||||
|
||||
- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented
|
||||
- `api_version` is `3`
|
||||
- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed
|
||||
- the `Kindling` and `Sparks` super groups are not returned
|
||||
- `feeds[].is_spark` is always `0`
|
||||
- item ordering without explicit pagination parameters is unspecified
|
||||
- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely
|
||||
|
||||
## Examples
|
||||
|
||||
Fetch groups:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'
|
||||
```
|
||||
|
||||
Fetch most recent items:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'
|
||||
```
|
||||
|
||||
Fetch items after a known ID:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'
|
||||
```
|
||||
|
||||
Mark an item as read:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=item' \
|
||||
-d 'as=read' \
|
||||
-d 'id=123'
|
||||
```
|
||||
|
||||
Mark a feed as read before a timestamp:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=feed' \
|
||||
-d 'as=read' \
|
||||
-d 'id=10' \
|
||||
-d 'before=1710000000'
|
||||
```
|
||||
|
||||
Mark all items as read through the group endpoint:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=group' \
|
||||
-d 'as=read' \
|
||||
-d 'id=0'
|
||||
```
|
||||
+86
-107
@@ -11,30 +11,24 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Serve handles Fever API calls.
|
||||
func Serve(router *mux.Router, store *storage.Storage) {
|
||||
handler := &handler{store, router}
|
||||
|
||||
sr := router.PathPrefix("/fever").Subrouter()
|
||||
sr.Use(newMiddleware(store).serve)
|
||||
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
|
||||
// NewHandler returns an http.Handler for Fever API calls.
|
||||
func NewHandler(store *storage.Storage) http.Handler {
|
||||
h := &feverHandler{store: store}
|
||||
return http.HandlerFunc(h.serve)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
type feverHandler struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case request.HasQueryParam(r, "groups"):
|
||||
h.handleGroups(w, r)
|
||||
@@ -55,7 +49,7 @@ func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
case r.FormValue("mark") == "group":
|
||||
h.handleWriteGroups(w, r)
|
||||
default:
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +72,7 @@ an is_spark equal to 0.
|
||||
The “Sparks” super group is not included in this response and is composed of all feeds with an
|
||||
is_spark equal to 1.
|
||||
*/
|
||||
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching groups",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -86,13 +80,13 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
categories, err := h.store.Categories(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.Feeds(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,9 +95,9 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
result.FeedsGroups = buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -130,7 +124,7 @@ should be limited to feeds with an is_spark equal to 0.
|
||||
|
||||
For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
|
||||
*/
|
||||
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching feeds",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -138,7 +132,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
feeds, err := h.store.Feeds(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,9 +155,9 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
result.Feeds = append(result.Feeds, subscription)
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
result.FeedsGroups = buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -185,7 +179,7 @@ A PHP/HTML example:
|
||||
|
||||
echo '<img src="data:'.$favicon['data'].'">';
|
||||
*/
|
||||
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching favicons",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -193,7 +187,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
icons, err := h.store.Icons(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,7 +200,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -239,14 +233,13 @@ Three optional arguments control determine the items included in the response.
|
||||
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
|
||||
(added in API version 2)
|
||||
*/
|
||||
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
var result itemsResponse
|
||||
|
||||
userID := request.UserID(r)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(50)
|
||||
builder := h.store.NewEntryQueryBuilder(userID).
|
||||
WithLimit(50)
|
||||
|
||||
switch {
|
||||
case request.HasQueryParam(r, "since_id"):
|
||||
@@ -256,8 +249,8 @@ func (h *handler) 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)
|
||||
@@ -265,14 +258,14 @@ func (h *handler) 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", "")
|
||||
@@ -285,7 +278,7 @@ func (h *handler) 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",
|
||||
@@ -295,15 +288,14 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
result.Total, err = builder.CountEntries()
|
||||
result.Total, err = h.store.NewEntryQueryBuilder(userID).
|
||||
CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -324,7 +316,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
FeedID: entry.FeedID,
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content),
|
||||
URL: entry.URL,
|
||||
IsSaved: isSaved,
|
||||
IsRead: isRead,
|
||||
@@ -333,7 +325,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -344,17 +336,17 @@ A request with the unread_item_ids argument will return one additional member:
|
||||
|
||||
unread_item_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching unread items",
|
||||
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 {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -366,7 +358,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
var result unreadResponse
|
||||
result.ItemIDs = strings.Join(itemIDs, ",")
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -377,18 +369,17 @@ with the remote Fever installation.
|
||||
|
||||
saved_item_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching saved items",
|
||||
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 {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -399,7 +390,7 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -407,7 +398,7 @@ mark=item
|
||||
as=? where ? is replaced with read, saved or unsaved
|
||||
id=? where ? is replaced with the id of the item to modify
|
||||
*/
|
||||
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Receiving mark=item call",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -418,13 +409,11 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,7 +422,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -456,13 +445,13 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -475,12 +464,12 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -489,7 +478,7 @@ as=read
|
||||
id=? where ? is replaced with the id of the feed or group to modify
|
||||
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
|
||||
*/
|
||||
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
feedID := request.FormInt64Value(r, "id")
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
@@ -504,18 +493,12 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
|
||||
slog.Error("[Fever] Unable to mark feed as read",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Time("before_ts", before),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -524,41 +507,37 @@ as=read
|
||||
id=? where ? is replaced with the id of the feed or group to modify
|
||||
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
|
||||
*/
|
||||
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
groupID := request.FormInt64Value(r, "id")
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
|
||||
slog.Debug("[Fever] Mark group as read before a given date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
)
|
||||
|
||||
if groupID < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
var err error
|
||||
|
||||
if groupID == 0 {
|
||||
err = h.store.MarkAllAsRead(userID)
|
||||
} else {
|
||||
err = h.store.MarkCategoryAsRead(userID, groupID, before)
|
||||
}
|
||||
if groupID == 0 {
|
||||
err = h.store.MarkAllAsRead(userID)
|
||||
slog.Debug("[Fever] Mark all items as read",
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
} else {
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
err = h.store.MarkCategoryAsRead(userID, groupID, before)
|
||||
slog.Debug("[Fever] Mark group as read before a given date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to mark group as read",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -567,7 +546,7 @@ A feeds_group object has the following members:
|
||||
group_id (positive integer)
|
||||
feed_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
func buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
|
||||
for _, feed := range feeds {
|
||||
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
|
||||
|
||||
@@ -9,70 +9,65 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
// Middleware returns the Fever authentication middleware.
|
||||
func Middleware(store *storage.Storage) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
slog.Warn("[Fever] No API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
user, err := store.UserByFeverToken(apiKey)
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to fetch user by API key",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
func (m *middleware) serve(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
slog.Warn("[Fever] No API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
if user == nil {
|
||||
slog.Warn("[Fever] No user found with the API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("[Fever] User authenticated successfully",
|
||||
slog.Bool("authentication_successful", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("username", user.Username),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByFeverToken(apiKey)
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to fetch user by API key",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
store.SetLastLogin(user.ID)
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[Fever] No user found with the API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
|
||||
slog.Info("[Fever] User authenticated successfully",
|
||||
slog.Bool("authentication_successful", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("username", user.Username),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# Miniflux Google Reader API
|
||||
|
||||
This document describes the Google Reader compatible API implemented by the `internal/googlereader` package in this repository.
|
||||
|
||||
Miniflux implements a compatibility subset intended for existing Google Reader clients. It is not a full reimplementation of the historical Google Reader API, and several behaviors are intentionally narrower or implementation-specific.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Client login path: `BASE_URL/accounts/ClientLogin`
|
||||
- API prefix: `BASE_URL/reader/api/0`
|
||||
- `BASE_URL` includes the Miniflux root URL and any configured `BasePath`
|
||||
- Response format:
|
||||
- `ClientLogin`: plain text by default, JSON when `output=json`
|
||||
- most API reads: JSON
|
||||
- most API writes: plain text `OK`
|
||||
|
||||
## Enabling the API
|
||||
|
||||
Google Reader compatibility is configured per user from the Miniflux integrations page.
|
||||
|
||||
- `Google Reader API` must be enabled
|
||||
- `Google Reader Username` must be unique across all Miniflux users
|
||||
- `Google Reader Password` is stored as a bcrypt hash
|
||||
|
||||
The Google Reader username and password are separate integration credentials. They are not the Miniflux account password.
|
||||
|
||||
## Authentication
|
||||
|
||||
### `POST /accounts/ClientLogin`
|
||||
|
||||
This endpoint exchanges the configured Google Reader username and password for an auth token.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `Email`: Google Reader username
|
||||
- `Passwd`: Google Reader password
|
||||
- `output`: optional, set to `json` for a JSON response
|
||||
|
||||
Successful responses:
|
||||
|
||||
- default: plain text
|
||||
- with `output=json`: JSON
|
||||
|
||||
Example plain-text response:
|
||||
|
||||
```text
|
||||
SID=readeruser/0123456789abcdef...
|
||||
LSID=readeruser/0123456789abcdef...
|
||||
Auth=readeruser/0123456789abcdef...
|
||||
```
|
||||
|
||||
Example JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"SID": "readeruser/0123456789abcdef...",
|
||||
"LSID": "readeruser/0123456789abcdef...",
|
||||
"Auth": "readeruser/0123456789abcdef..."
|
||||
}
|
||||
```
|
||||
|
||||
On authentication failure, `ClientLogin` returns HTTP `401` with the normal JSON error body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "access unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
### Auth token format
|
||||
|
||||
The token format is:
|
||||
|
||||
```text
|
||||
<googlereader_username>/<hex_digest>
|
||||
```
|
||||
|
||||
The digest is generated server-side from:
|
||||
|
||||
- the Google Reader username
|
||||
- the stored bcrypt hash of the Google Reader password
|
||||
|
||||
Specifically, the code computes an HMAC-SHA256 digest of an empty message using the key:
|
||||
|
||||
```text
|
||||
googlereader_username + bcrypt_hash
|
||||
```
|
||||
|
||||
Because the bcrypt hash is only known to the server, clients should not try to precompute the token. Use `ClientLogin` or `GET /reader/api/0/token`.
|
||||
|
||||
### Authenticating API calls
|
||||
|
||||
Miniflux uses different auth mechanisms for `GET` and `POST` requests:
|
||||
|
||||
- `GET` requests must send the header `Authorization: GoogleLogin auth=<token>`
|
||||
- `POST` requests are authenticated with `T=<token>` read from the parsed form values
|
||||
|
||||
Notes:
|
||||
|
||||
- the auth scheme must be exactly `GoogleLogin`
|
||||
- the auth field name must be exactly lowercase `auth`
|
||||
- for `POST`, `T` may come from the URL query or the form body because the server reads merged form values
|
||||
- `POST` requests do not accept the token from the `Authorization` header
|
||||
- `GET` requests do not accept the token from the query string
|
||||
|
||||
### `GET /reader/api/0/token`
|
||||
|
||||
This endpoint requires normal `GET` authentication and returns the same token as plain text.
|
||||
|
||||
Many Google Reader clients use this as the edit token for subsequent write requests. In Miniflux, the edit token and auth token are the same value.
|
||||
|
||||
### Authentication failure on `/reader/api/0/*`
|
||||
|
||||
When API authentication fails under `/reader/api/0`, Miniflux returns:
|
||||
|
||||
- HTTP `401`
|
||||
- header `X-Reader-Google-Bad-Token: true`
|
||||
- content type `text/plain; charset=utf-8`
|
||||
- body `Unauthorized`
|
||||
|
||||
This is different from `ClientLogin`, which returns a JSON `401`.
|
||||
|
||||
## Identifier formats
|
||||
|
||||
### Stream IDs
|
||||
|
||||
The implementation recognizes these stream forms:
|
||||
|
||||
- built-in streams:
|
||||
- `user/-/state/com.google/read`
|
||||
- `user/-/state/com.google/starred`
|
||||
- `user/-/state/com.google/reading-list`
|
||||
- `user/-/state/com.google/kept-unread`
|
||||
- `user/-/state/com.google/broadcast`
|
||||
- `user/-/state/com.google/broadcast-friends`
|
||||
- `user/-/state/com.google/like`
|
||||
- user-specific equivalents:
|
||||
- `user/<user_id>/state/com.google/...`
|
||||
- label streams:
|
||||
- `user/-/label/<name>`
|
||||
- `user/<user_id>/label/<name>`
|
||||
- feed streams:
|
||||
- `feed/<value>`
|
||||
|
||||
Important feed stream difference:
|
||||
|
||||
- read APIs usually emit `feed/<numeric_feed_id>`
|
||||
- `subscription/edit` with `ac=subscribe` expects `feed/<absolute_feed_url>`
|
||||
- `subscription/edit` with `ac=edit` or `ac=unsubscribe` expects `feed/<numeric_feed_id>`
|
||||
|
||||
So `feed/<...>` is not a single stable identifier format across all endpoints.
|
||||
|
||||
### Item IDs
|
||||
|
||||
`edit-tag` and `stream/items/contents` accept repeated `i` parameters in all of these formats:
|
||||
|
||||
- long Google Reader form: `tag:google.com,2005:reader/item/00000000148b9369`
|
||||
- short prefixed hexadecimal form: `tag:google.com,2005:reader/item/2f2`
|
||||
- bare 16-character hexadecimal form: `000000000000048c`
|
||||
- decimal entry ID: `12345`
|
||||
|
||||
Responses use different forms depending on endpoint:
|
||||
|
||||
- `stream/items/ids` returns decimal IDs as strings
|
||||
- `stream/items/contents` returns long-form Google Reader item IDs
|
||||
|
||||
## Common response conventions
|
||||
|
||||
JSON errors use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Plain-text success responses from write endpoints are usually:
|
||||
|
||||
```text
|
||||
OK
|
||||
```
|
||||
|
||||
## POST parameter parsing
|
||||
|
||||
Most `POST` handlers call `ParseForm()` and read from `r.Form`, so parameters may be supplied either in the query string or in a standard form body.
|
||||
|
||||
Important exception:
|
||||
|
||||
- `POST /reader/api/0/edit-tag` reads `a` and `r` from `r.PostForm`, so those tag lists must come from the request body
|
||||
|
||||
Because `GET` auth comes only from the `Authorization` header, query parameters never authenticate `GET` requests even when other parameters are read from the query string.
|
||||
|
||||
## Endpoint reference
|
||||
|
||||
### `GET /reader/api/0/user-info`
|
||||
|
||||
Returns JSON only. No `output=json` parameter is required.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `userId`: Miniflux user ID as a string
|
||||
- `userName`: Miniflux username
|
||||
- `userProfileId`: same value as `userId`
|
||||
- `userEmail`: same value as `userName`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "1",
|
||||
"userName": "demo",
|
||||
"userProfileId": "1",
|
||||
"userEmail": "demo"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /reader/api/0/tag/list?output=json`
|
||||
|
||||
Returns the starred state and user labels.
|
||||
|
||||
Notes:
|
||||
|
||||
- `output=json` is required
|
||||
- only labels and the starred state are returned
|
||||
- built-in states such as `read` and `reading-list` are not listed here
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"id": "user/1/state/com.google/starred"
|
||||
},
|
||||
{
|
||||
"id": "user/1/label/Tech",
|
||||
"label": "Tech",
|
||||
"type": "folder"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /reader/api/0/subscription/list?output=json`
|
||||
|
||||
Returns the user's feeds.
|
||||
|
||||
Notes:
|
||||
|
||||
- `output=json` is required
|
||||
- each feed is reported with a numeric feed stream ID such as `feed/42`
|
||||
- `categories` always contains the Miniflux category as a Google Reader folder
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "feed/42",
|
||||
"title": "Example Feed",
|
||||
"categories": [
|
||||
{
|
||||
"id": "user/1/label/Tech",
|
||||
"label": "Tech",
|
||||
"type": "folder"
|
||||
}
|
||||
],
|
||||
"url": "https://example.org/feed.xml",
|
||||
"htmlUrl": "https://example.org/",
|
||||
"iconUrl": "https://miniflux.example.com/icon/..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /reader/api/0/subscription/quickadd`
|
||||
|
||||
Subscribes to the first discovered feed for the given absolute URL.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `quickadd`: absolute URL
|
||||
|
||||
Response shape when a feed is found:
|
||||
|
||||
```json
|
||||
{
|
||||
"numResults": 1,
|
||||
"query": "https://example.org/feed.xml",
|
||||
"streamId": "feed/42",
|
||||
"streamName": "Example Feed"
|
||||
}
|
||||
```
|
||||
|
||||
Response shape when no feed is found:
|
||||
|
||||
```json
|
||||
{
|
||||
"numResults": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- the request URL must be absolute
|
||||
- the created subscription is assigned to the user's first category when no explicit category is provided
|
||||
|
||||
### `POST /reader/api/0/subscription/edit`
|
||||
|
||||
Edits subscriptions. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `ac`: action
|
||||
- `s`: repeated stream ID
|
||||
- `a`: optional destination label stream
|
||||
- `t`: optional title
|
||||
|
||||
Supported actions:
|
||||
|
||||
- `ac=subscribe`
|
||||
- `ac=unsubscribe`
|
||||
- `ac=edit`
|
||||
|
||||
Behavior by action:
|
||||
|
||||
- `subscribe`
|
||||
- only the first `s` value is used
|
||||
- `s` must be `feed/<absolute_feed_url>`
|
||||
- `a`, when present, must be a label stream
|
||||
- `t`, when present, becomes the feed title after creation
|
||||
- `unsubscribe`
|
||||
- every `s` must be `feed/<numeric_feed_id>`
|
||||
- `edit`
|
||||
- only the first `s` value is used
|
||||
- `s` must be `feed/<numeric_feed_id>`
|
||||
- `t` renames the feed
|
||||
- `a` moves the feed to a label, and must be a label stream
|
||||
|
||||
Notable limitations:
|
||||
|
||||
- removing a label is not implemented here
|
||||
- `subscribe`, `edit`, and `unsubscribe` do not share the same feed ID format
|
||||
|
||||
### `POST /reader/api/0/rename-tag`
|
||||
|
||||
Renames a label. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: source label stream
|
||||
- `dest`: destination label stream
|
||||
|
||||
Rules:
|
||||
|
||||
- both `s` and `dest` must be label streams
|
||||
- the destination label name must not be empty
|
||||
- if the source label does not exist, the endpoint returns HTTP `404`
|
||||
|
||||
### `POST /reader/api/0/disable-tag`
|
||||
|
||||
Deletes one or more labels and reassigns affected feeds to the user's first remaining category.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: repeated label stream
|
||||
|
||||
Rules:
|
||||
|
||||
- only label streams are supported
|
||||
- at least one category must remain after deletion, otherwise the operation fails
|
||||
|
||||
Successful requests return plain text `OK`.
|
||||
|
||||
### `POST /reader/api/0/edit-tag`
|
||||
|
||||
Marks entries read or unread and starred or unstarred.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `i`: repeated item ID
|
||||
- `a`: repeated tag stream to add
|
||||
- `r`: repeated tag stream to remove
|
||||
|
||||
Supported tag semantics:
|
||||
|
||||
- add `user/.../state/com.google/read`: mark read
|
||||
- remove `user/.../state/com.google/read`: mark unread
|
||||
- add `user/.../state/com.google/kept-unread`: mark unread
|
||||
- remove `user/.../state/com.google/kept-unread`: mark read
|
||||
- add `user/.../state/com.google/starred`: star
|
||||
- remove `user/.../state/com.google/starred`: unstar
|
||||
|
||||
Special cases:
|
||||
|
||||
- `read` and `kept-unread` cannot be combined in conflicting ways in the same request
|
||||
- `starred` cannot be present in both add and remove
|
||||
- `broadcast` and `like` are recognized but ignored
|
||||
- unsupported tag types cause an error
|
||||
|
||||
Successful requests return plain text `OK`.
|
||||
|
||||
### `GET /reader/api/0/stream/items/ids?output=json`
|
||||
|
||||
Returns item IDs for one stream.
|
||||
|
||||
Required query parameters:
|
||||
|
||||
- `output=json`
|
||||
- `s=<stream_id>`
|
||||
|
||||
Optional query parameters:
|
||||
|
||||
- `n`: maximum number of items to return
|
||||
- `c`: numeric offset continuation token
|
||||
- `r`: sort direction, `o` for ascending, anything else for descending
|
||||
- `ot`: only items published after this Unix timestamp in seconds
|
||||
- `nt`: only items published before this Unix timestamp in seconds
|
||||
- `xt`: repeated exclude target stream
|
||||
- `it`: repeated filter target stream, parsed but currently ignored
|
||||
|
||||
Supported `s` values:
|
||||
|
||||
- `user/.../state/com.google/reading-list`
|
||||
- `user/.../state/com.google/starred`
|
||||
- `user/.../state/com.google/read`
|
||||
- `feed/<numeric_feed_id>`
|
||||
|
||||
Notes:
|
||||
|
||||
- exactly one `s` value is expected
|
||||
- label streams are not supported here
|
||||
- when `xt` contains the `read` stream, `reading-list` and `feed/<id>` behave as unread-only queries
|
||||
- if `n` is omitted, the query is effectively unbounded
|
||||
- `continuation` is a numeric offset encoded as a JSON string, not an opaque token
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"itemRefs": [
|
||||
{
|
||||
"id": "12345"
|
||||
},
|
||||
{
|
||||
"id": "12344"
|
||||
}
|
||||
],
|
||||
"continuation": "2"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /reader/api/0/stream/items/contents`
|
||||
|
||||
Returns content for specific items.
|
||||
|
||||
Required parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `output=json`
|
||||
- `i`: repeated item ID
|
||||
|
||||
Optional query parameters:
|
||||
|
||||
- `r`: sort direction, `o` for ascending, anything else for descending
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- the route is `POST` only
|
||||
- `T`, `output`, and `i` are read from merged form values, so they may be supplied in the query string or the form body
|
||||
- the handler parses stream filter query parameters, but in practice only the sort direction affects the result
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"direction": "ltr",
|
||||
"id": "user/-/state/com.google/reading-list",
|
||||
"title": "Reading List",
|
||||
"self": [
|
||||
{
|
||||
"href": "https://miniflux.example.com/reader/api/0/stream/items/contents"
|
||||
}
|
||||
],
|
||||
"updated": 1710000000,
|
||||
"author": "demo",
|
||||
"items": [
|
||||
{
|
||||
"id": "tag:google.com,2005:reader/item/00000000148b9369",
|
||||
"categories": [
|
||||
"user/1/state/com.google/reading-list",
|
||||
"user/1/label/Tech",
|
||||
"user/1/state/com.google/starred"
|
||||
],
|
||||
"title": "Example entry",
|
||||
"crawlTimeMsec": "1710000000123",
|
||||
"timestampUsec": "1710000000123456",
|
||||
"published": 1710000000,
|
||||
"updated": 1710000300,
|
||||
"author": "Author",
|
||||
"alternate": [
|
||||
{
|
||||
"href": "https://example.org/post",
|
||||
"type": "text/html"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"direction": "ltr",
|
||||
"content": "<p>Content</p>"
|
||||
},
|
||||
"content": {
|
||||
"direction": "ltr",
|
||||
"content": "<p>Content</p>"
|
||||
},
|
||||
"origin": {
|
||||
"streamId": "feed/42",
|
||||
"title": "Example Feed",
|
||||
"htmlUrl": "https://example.org/"
|
||||
},
|
||||
"enclosure": [],
|
||||
"canonical": [
|
||||
{
|
||||
"href": "https://example.org/post"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- top-level `id` and `title` are hard-coded as the reading list
|
||||
- `summary.content` and `content.content` both contain the rewritten entry content
|
||||
- enclosure URLs and embedded media may be rewritten through the Miniflux media proxy
|
||||
|
||||
### `POST /reader/api/0/mark-all-as-read`
|
||||
|
||||
Marks items as read before a timestamp. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: stream ID
|
||||
- `ts`: optional timestamp
|
||||
|
||||
Supported `s` values:
|
||||
|
||||
- `feed/<numeric_feed_id>`
|
||||
- `user/.../label/<name>`
|
||||
- `user/.../state/com.google/reading-list`
|
||||
|
||||
Timestamp handling:
|
||||
|
||||
- if `ts` has at least 16 digits, it is interpreted as microseconds since the Unix epoch
|
||||
- otherwise it is interpreted as seconds since the Unix epoch
|
||||
- if `ts` is omitted, Miniflux uses the current server time
|
||||
|
||||
Notes:
|
||||
|
||||
- only unread entries published before `ts` are marked as read
|
||||
- unsupported stream types are effectively a no-op and still return `OK`
|
||||
|
||||
### Catch-all unimplemented endpoints
|
||||
|
||||
Any other `GET` or `POST` path under `/reader/api/0/` is caught by the fallback handler and returns:
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
with HTTP `200`.
|
||||
|
||||
## Compatibility notes and deviations
|
||||
|
||||
These differences are important for client authors:
|
||||
|
||||
- only a subset of Google Reader endpoints is implemented
|
||||
- feed stream IDs are numeric in read responses, but `ac=subscribe` expects `feed/<absolute_feed_url>`
|
||||
- `stream/items/ids` returns decimal entry IDs, while `stream/items/contents` returns long-form Google Reader item IDs
|
||||
- pagination uses `c` as a numeric SQL offset, not an opaque continuation token
|
||||
- `it` filter targets are parsed but currently ignored
|
||||
- `tag/list` returns only `starred` and user labels
|
||||
- API auth failures under `/reader/api/0/*` return plain text `401 Unauthorized`, not JSON
|
||||
- unknown `/reader/api/0/*` endpoints return `[]` with `200`, not `404`
|
||||
+328
-368
File diff suppressed because it is too large
Load Diff
+155
-150
@@ -6,176 +6,181 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
type authMiddleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
func newAuthMiddleware(s *storage.Storage) *authMiddleware {
|
||||
return &authMiddleware{s}
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if expectedToken != token {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
m.serveValidated(w, r, next)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *authMiddleware) serveValidated(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if !crypto.ConstantTimeCmp(expectedToken, token) {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func getAuthToken(username, password string) string {
|
||||
token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))
|
||||
token := hex.EncodeToString(hmac.New(sha256.New, []byte(username+password)).Sum(nil))
|
||||
token = username + "/" + token
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
type loginResponse struct {
|
||||
@@ -117,15 +119,11 @@ type contentItemOrigin struct {
|
||||
HTMLUrl string `json:"htmlUrl"`
|
||||
}
|
||||
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("X-Reader-Google-Bad-Token", "true")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorized"))
|
||||
}
|
||||
|
||||
func sendOkayResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/internal/http/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
|
||||
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
|
||||
|
||||
// Options holds configuration for creating an HTTP client.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// NewClientWithOptions creates a new HTTP client with the specified options.
|
||||
func NewClientWithOptions(opts Options) *http.Client {
|
||||
if !opts.BlockPrivateNetworks {
|
||||
return &http.Client{Timeout: opts.Timeout}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: opts.Timeout,
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
|
||||
}
|
||||
|
||||
var safeIP net.IP
|
||||
for _, ip := range ips {
|
||||
if !urllib.IsNonPublicIP(ip) {
|
||||
safeIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if safeIP == nil {
|
||||
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
|
||||
}
|
||||
|
||||
safeAddr := net.JoinHostPort(safeIP.String(), port)
|
||||
return dialer.DialContext(ctx, network, safeAddr)
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err := client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error when connecting to loopback address, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
if client.Transport != nil {
|
||||
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create listener: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err = client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when connecting to private IP")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Expired returns an expired cookie.
|
||||
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func basePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -21,26 +19,16 @@ const (
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
UserSessionTokenContextKey
|
||||
UserLanguageContextKey
|
||||
UserThemeContextKey
|
||||
SessionIDContextKey
|
||||
CSRFContextKey
|
||||
OAuth2StateContextKey
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
LastForceRefreshContextKey
|
||||
WebSessionContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
|
||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||
if value, valid := v.(model.WebAuthnSession); valid {
|
||||
return &value
|
||||
// WebSession returns the current web session from the request context, if present.
|
||||
func WebSession(r *http.Request) *model.WebSession {
|
||||
if v := r.Context().Value(WebSessionContextKey); v != nil {
|
||||
if value, valid := v.(*model.WebSession); valid {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -58,12 +46,30 @@ func IsAdminUser(r *http.Request) bool {
|
||||
|
||||
// IsAuthenticated reports whether the user is authenticated.
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAuthenticatedContextKey)
|
||||
if getContextBoolValue(r, IsAuthenticatedContextKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
return session.IsAuthenticated()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UserID returns the logged-in user's ID from the request context.
|
||||
func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
|
||||
return userID
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
if id, ok := session.UserID(); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
@@ -84,69 +90,6 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
language = "en_US"
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
theme = "system_serif"
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the CSRF token from the request context.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID from the request context.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token from the request context.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the OAuth2 state value from the request context.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the flash message from the request context, if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the flash error message from the request context, if any.
|
||||
func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp from the request context.
|
||||
func LastForceRefresh(r *http.Request) time.Time {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
|
||||
@@ -7,11 +7,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func newRequestWithWebSession(session *model.WebSession) *http.Request {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestContextStringValue(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := r.Context()
|
||||
@@ -171,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 42})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = IsAuthenticated(r)
|
||||
if !result {
|
||||
t.Errorf("Unexpected context value, got %v instead of true", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserID(t *testing.T) {
|
||||
@@ -193,6 +207,17 @@ func TestUserID(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 456})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = UserID(r)
|
||||
expected = int64(456)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserName(t *testing.T) {
|
||||
@@ -239,262 +264,21 @@ func TestUserTimezone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLanguage(t *testing.T) {
|
||||
func TestWebSession(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserLanguage(r)
|
||||
expected := "en_US"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
if result := WebSession(r); result != nil {
|
||||
t.Fatalf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := &model.WebSession{ID: "session-id"}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
|
||||
ctx = context.WithValue(ctx, WebSessionContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserLanguage(r)
|
||||
expected = "fr_FR"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTheme(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserTheme(r)
|
||||
expected := "system_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserTheme(r)
|
||||
expected = "dark_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := CSRF(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = CSRF(r)
|
||||
expected = "secret"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionID(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := SessionID(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = SessionID(r)
|
||||
expected = "id"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSessionToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserSessionToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserSessionToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2State(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2State(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2State(r)
|
||||
expected = "state"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2CodeVerifier(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2CodeVerifier(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2CodeVerifier(r)
|
||||
expected = "verifier"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashMessage(r)
|
||||
expected = "message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashErrorMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashErrorMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashErrorMessage(r)
|
||||
expected = "error message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastForceRefresh(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := LastForceRefresh(r)
|
||||
expected := time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Unix(1700000000, 0)
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnSessionData(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := model.WebAuthnSession{}
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result == nil {
|
||||
t.Errorf("Unexpected context value, got nil instead of session")
|
||||
result := WebSession(r)
|
||||
if result == nil || result.ID != "session-id" {
|
||||
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
|
||||
@@ -24,8 +22,7 @@ func FormInt64Value(r *http.Request, param string) int64 {
|
||||
|
||||
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
|
||||
func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
vars := mux.Vars(r)
|
||||
value, err := strconv.ParseInt(vars[param], 10, 64)
|
||||
value, err := strconv.ParseInt(routeParam(r, param), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -39,8 +36,7 @@ func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
|
||||
// RouteStringParam returns the named route parameter as a string.
|
||||
func RouteStringParam(r *http.Request, param string) string {
|
||||
vars := mux.Vars(r)
|
||||
return vars[param]
|
||||
return routeParam(r, param)
|
||||
}
|
||||
|
||||
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
|
||||
@@ -129,3 +125,7 @@ func HasQueryParam(r *http.Request, param string) bool {
|
||||
_, ok := values[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
func routeParam(r *http.Request, param string) string {
|
||||
return r.PathValue(param)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestFormInt64Value(t *testing.T) {
|
||||
@@ -42,9 +40,9 @@ func TestFormInt64Value(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStringParam(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
|
||||
func TestRouteStringParamWithServerMux(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
|
||||
result := RouteStringParam(r, "variable")
|
||||
expected := "value"
|
||||
|
||||
@@ -60,7 +58,7 @@ func TestRouteStringParam(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
r, err := http.NewRequest("GET", "/route/value/index", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/route/value/index", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -69,9 +67,9 @@ func TestRouteStringParam(t *testing.T) {
|
||||
router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func TestRouteInt64Param(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
|
||||
func TestRouteInt64ParamWithServerMux(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
|
||||
result := RouteInt64Param(r, "variable1")
|
||||
expected := int64(42)
|
||||
|
||||
@@ -101,7 +99,7 @@ func TestRouteInt64Param(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
r, err := http.NewRequest("GET", "/a/42/b/not-int/c/-10", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/a/42/b/not-int/c/-10", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,11 +25,16 @@ 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(http.Header), enableCompression: true}
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
func (b *Builder) WithStatus(statusCode int) *Builder {
|
||||
b.statusCode = statusCode
|
||||
@@ -35,19 +43,37 @@ 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
|
||||
}
|
||||
|
||||
// WithBody uses the given body to build the response.
|
||||
func (b *Builder) WithBody(body any) *Builder {
|
||||
// WithBodyAsBytes uses the given bytes to build the response.
|
||||
func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBodyAsString uses the given string to build the response.
|
||||
func (b *Builder) WithBodyAsString(body string) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBodyAsReader uses the given reader to build the response.
|
||||
func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
// WithAttachment forces the document to be downloaded by the web browser.
|
||||
func (b *Builder) WithAttachment(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = "attachment; filename=" + 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.Set("Content-Disposition", formatContentDisposition("inline", filename))
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -59,11 +85,14 @@ func (b *Builder) WithoutCompression() *Builder {
|
||||
|
||||
// WithCaching adds caching headers to the response.
|
||||
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
||||
b.headers["ETag"] = etag
|
||||
b.headers["Cache-Control"] = "public"
|
||||
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
|
||||
etag = normalizeETag(etag)
|
||||
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 etag == b.r.Header.Get("If-None-Match") {
|
||||
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
|
||||
b.statusCode = http.StatusNotModified
|
||||
b.body = nil
|
||||
b.Write()
|
||||
@@ -95,23 +124,22 @@ func (b *Builder) Write() {
|
||||
}
|
||||
|
||||
func (b *Builder) writeHeaders() {
|
||||
b.headers["X-Content-Type-Options"] = "nosniff"
|
||||
b.headers["X-Frame-Options"] = "DENY"
|
||||
b.headers["Referrer-Policy"] = "no-referrer"
|
||||
b.headers.Set("X-Content-Type-Options", "nosniff")
|
||||
b.headers.Set("X-Frame-Options", "DENY")
|
||||
b.headers.Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
for key, value := range b.headers {
|
||||
b.w.Header().Set(key, value)
|
||||
}
|
||||
maps.Copy(b.w.Header(), b.headers)
|
||||
|
||||
b.w.WriteHeader(b.statusCode)
|
||||
}
|
||||
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
b.headers.Set("Vary", "Accept-Encoding")
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.headers.Set("Content-Encoding", "br")
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
@@ -119,7 +147,7 @@ func (b *Builder) compress(data []byte) {
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.headers.Set("Content-Encoding", "gzip")
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
@@ -127,7 +155,7 @@ func (b *Builder) compress(data []byte) {
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.headers.Set("Content-Encoding", "deflate")
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
@@ -141,7 +169,36 @@ func (b *Builder) compress(data []byte) {
|
||||
b.w.Write(data)
|
||||
}
|
||||
|
||||
// New creates a new response builder.
|
||||
func New(w http.ResponseWriter, r *http.Request) *Builder {
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
|
||||
func normalizeETag(etag string) string {
|
||||
etag = strings.TrimSpace(etag)
|
||||
if etag == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
|
||||
return etag
|
||||
}
|
||||
return `"` + etag + `"`
|
||||
}
|
||||
|
||||
func ifNoneMatch(headerValue, etag string) bool {
|
||||
if headerValue == "" || etag == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(headerValue) == "*" {
|
||||
return true
|
||||
}
|
||||
// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
|
||||
return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
|
||||
}
|
||||
|
||||
func formatContentDisposition(dispositionType, filename string) string {
|
||||
if filename == "" {
|
||||
return dispositionType
|
||||
}
|
||||
|
||||
if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
return dispositionType
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -20,7 +22,7 @@ func TestResponseHasCommonHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).Write()
|
||||
NewBuilder(w, r).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -48,7 +50,7 @@ func TestBuildResponseWithCustomStatusCode(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithStatus(http.StatusNotAcceptable).Write()
|
||||
NewBuilder(w, r).WithStatus(http.StatusNotAcceptable).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -69,7 +71,7 @@ func TestBuildResponseWithCustomHeader(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithHeader("X-My-Header", "Value").Write()
|
||||
NewBuilder(w, r).WithHeader("X-My-Header", "Value").Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -91,7 +93,7 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithAttachment("my_file.pdf").Write()
|
||||
NewBuilder(w, r).WithAttachment("my_file.pdf").Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -104,6 +106,90 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "attachment" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContentDisposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dispositionType string
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
{"empty filename returns bare type", "inline", "", "inline"},
|
||||
{"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
|
||||
{"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
|
||||
{"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
|
||||
{"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := formatContentDisposition(tt.dispositionType, tt.filename)
|
||||
if actual != tt.expected {
|
||||
t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
@@ -113,7 +199,7 @@ func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody([]byte("body")).Write()
|
||||
NewBuilder(w, r).WithBodyAsBytes([]byte("body")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -134,8 +220,8 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBody("cached body")
|
||||
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBodyAsString("cached body")
|
||||
b.Write()
|
||||
})
|
||||
})
|
||||
@@ -154,55 +240,118 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedHeader := "public"
|
||||
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)
|
||||
}
|
||||
|
||||
if actualETag := resp.Header.Get("ETag"); actualETag != `"etag"` {
|
||||
t.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `"etag"`)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithCachingAndEtag(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("If-None-Match", "etag")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ifNoneMatch string
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{"matching strong etag", `"etag"`, http.StatusNotModified, ""},
|
||||
{"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
|
||||
{"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
|
||||
{"wildcard", `*`, http.StatusNotModified, ""},
|
||||
{"non-matching etag", `"different"`, http.StatusOK, "cached body"},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Header.Set("If-None-Match", tt.ifNoneMatch)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBody("cached body")
|
||||
b.Write()
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBodyAsString("cached body")
|
||||
b.Write()
|
||||
})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
|
||||
}
|
||||
|
||||
if actual := w.Body.String(); actual != tt.expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
|
||||
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNotModified
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
func TestNormalizeETag(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"abc", `"abc"`},
|
||||
{`"already-quoted"`, `"already-quoted"`},
|
||||
{`W/"weak"`, `W/"weak"`},
|
||||
{"", ""},
|
||||
{" spaced ", `"spaced"`},
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if actual := normalizeETag(tt.input); actual != tt.expected {
|
||||
t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNoneMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headerValue string
|
||||
etag string
|
||||
expected bool
|
||||
}{
|
||||
{"empty header", "", `"etag"`, false},
|
||||
{"empty etag", `"etag"`, "", false},
|
||||
{"exact match", `"etag"`, `"etag"`, true},
|
||||
{"weak vs strong match", `W/"etag"`, `"etag"`, true},
|
||||
{"wildcard", `*`, `"etag"`, true},
|
||||
{"no match", `"other"`, `"etag"`, false},
|
||||
{"match in list", `"a", "etag", "b"`, `"etag"`, true},
|
||||
{"no match in list", `"a", "b", "c"`, `"etag"`, false},
|
||||
}
|
||||
|
||||
expectedHeader := "public"
|
||||
actualHeader := resp.Header.Get("Cache-Control")
|
||||
if actualHeader != expectedHeader {
|
||||
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
|
||||
t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +366,7 @@ func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -241,7 +390,7 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -265,7 +414,7 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -276,6 +425,12 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := "Accept-Encoding"
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
@@ -289,7 +444,7 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).WithoutCompression().Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -300,6 +455,12 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := ""
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
@@ -313,7 +474,7 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -324,6 +485,12 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := ""
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
@@ -336,7 +503,7 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -347,4 +514,29 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := "Accept-Encoding"
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithReaderBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString("body")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != "body" {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "body")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// 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).
|
||||
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 = builder.WithBodyAsBytes(v)
|
||||
case string:
|
||||
builder = builder.WithBodyAsString(v)
|
||||
}
|
||||
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// HTMLServerError sends an internal error to the client.
|
||||
func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error(http.StatusText(http.StatusInternalServerError),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusInternalServerError),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Warn(http.StatusText(http.StatusBadRequest),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusBadRequest),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusForbidden),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusForbidden),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusNotFound),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusNotFound),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func HTMLRedirect(w http.ResponseWriter, r *http.Request, uri string) {
|
||||
if !urllib.IsRelativePath(uri) && !urllib.IsAbsoluteURL(uri) {
|
||||
HTMLBadRequest(w, r, fmt.Errorf("invalid redirect URL: %q", uri))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
}
|
||||
|
||||
// HTMLRequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
|
||||
func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
|
||||
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
|
||||
),
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
|
||||
import (
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
// OK creates a new HTML response with a 200 status code.
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(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.WithBody(body)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// ServerError sends an internal error to the client.
|
||||
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error(http.StatusText(http.StatusInternalServerError),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusInternalServerError),
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// BadRequest sends a bad request error to the client.
|
||||
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Warn(http.StatusText(http.StatusBadRequest),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusBadRequest),
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Forbidden sends a forbidden error to the client.
|
||||
func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusForbidden),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusForbidden),
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(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.WithBody("Access Forbidden")
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// NotFound sends a page not found error to the client.
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusNotFound),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusNotFound),
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(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.WithBody("Page Not Found")
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Redirect redirects the user to another location.
|
||||
func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
}
|
||||
|
||||
// RequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
|
||||
func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
|
||||
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusRequestedRangeNotSatisfiable),
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(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.WithBody("Range Not Satisfiable")
|
||||
builder.Write()
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOKResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, r, "Some HTML")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some HTML`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
|
||||
}
|
||||
|
||||
for header, expected := range headers {
|
||||
actual := resp.Header.Get(header)
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/plain; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequestResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusBadRequest
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/plain; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Forbidden(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusForbidden
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Access Forbidden`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NotFound(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNotFound
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Page Not Found`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Redirect(w, r, "/path")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusFound
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedResult := "/path"
|
||||
actualResult := resp.Header.Get("Location")
|
||||
if actualResult != expectedResult {
|
||||
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestedRangeNotSatisfiable(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
RequestedRangeNotSatisfiable(w, r, "bytes */12777")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusRequestedRangeNotSatisfiable
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedContentRangeHeader := "bytes */12777"
|
||||
actualContentRangeHeader := resp.Header.Get("Content-Range")
|
||||
if actualContentRangeHeader != expectedContentRangeHeader {
|
||||
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, expectedContentRangeHeader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTMLResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTML(w, r, "Some HTML")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `Some HTML` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some HTML`)
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
|
||||
}
|
||||
|
||||
for header, expected := range headers {
|
||||
if actual := resp.Header.Get(header); actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLServerErrorResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `Some error with injected HTML <script>alert('XSS')</script>` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML <script>alert('XSS')</script>`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLBadRequestResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLBadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `Some error with injected HTML <script>alert('XSS')</script>` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML <script>alert('XSS')</script>`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLForbiddenResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLForbidden(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `Access Forbidden` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Access Forbidden`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/html; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLNotFoundResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLNotFound(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `Page Not Found` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Page Not Found`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/html; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLRedirectResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLRedirect(w, r, "/path")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusFound)
|
||||
}
|
||||
|
||||
if actualResult := resp.Header.Get("Location"); actualResult != "/path" {
|
||||
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, "/path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLRedirectAcceptedTargets(t *testing.T) {
|
||||
scenarios := []string{
|
||||
"/feeds",
|
||||
"/category/1/entries",
|
||||
"https://example.org/article",
|
||||
"http://example.org/article",
|
||||
}
|
||||
|
||||
for _, target := range scenarios {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
HTMLRedirect(w, r, target)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf(`Unexpected status code for %q, got %d instead of %d`, target, resp.StatusCode, http.StatusFound)
|
||||
}
|
||||
|
||||
if actualResult := resp.Header.Get("Location"); actualResult != target {
|
||||
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLRedirectRejectsUnsafeTargets(t *testing.T) {
|
||||
scenarios := []string{
|
||||
"javascript:alert(1)",
|
||||
"JAVASCRIPT:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"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",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, target := range scenarios {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
HTMLRedirect(w, r, target)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf(`Expected 400 for %q, got %d`, target, resp.StatusCode)
|
||||
}
|
||||
|
||||
if location := resp.Header.Get("Location"); location != "" {
|
||||
t.Fatalf(`Expected no Location header for %q, got %q`, target, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLRequestedRangeNotSatisfiable(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
HTMLRequestedRangeNotSatisfiable(w, r, "bytes */12777")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusRequestedRangeNotSatisfiable)
|
||||
}
|
||||
|
||||
if actualContentRangeHeader := resp.Header.Get("Content-Range"); actualContentRangeHeader != "bytes */12777" {
|
||||
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, "bytes */12777")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
)
|
||||
|
||||
const jsonContentTypeHeader = `application/json`
|
||||
|
||||
// JSON creates a new JSON response with a 200 status code.
|
||||
func JSON(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(responseBody).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONCreated sends a created response to the client.
|
||||
func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusAccepted).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONServerError sends an internal error to the client.
|
||||
func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error(http.StatusText(http.StatusInternalServerError),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusInternalServerError),
|
||||
),
|
||||
)
|
||||
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusInternalServerError).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONBadRequest sends a bad request error to the client.
|
||||
func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Warn(http.StatusText(http.StatusBadRequest),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusBadRequest),
|
||||
),
|
||||
)
|
||||
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusBadRequest).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONUnauthorized sends a not authorized error to the client.
|
||||
func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusUnauthorized),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusUnauthorized),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func JSONForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusForbidden),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusForbidden),
|
||||
),
|
||||
)
|
||||
|
||||
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.
|
||||
func JSONNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusNotFound),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusNotFound),
|
||||
),
|
||||
)
|
||||
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNotFound).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
func generateJSONError(err error) []byte {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
encodedBody, _ := json.Marshal(errorMsg{ErrorMessage: err.Error()})
|
||||
return encodedBody
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package json // import "miniflux.app/v2/internal/http/response/json"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
const contentTypeHeader = `application/json`
|
||||
|
||||
// OK creates a new JSON response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Created sends a created response to the client.
|
||||
func Created(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// NoContent sends a no content response to the client.
|
||||
func NoContent(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusNoContent)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
func Accepted(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusAccepted)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// ServerError sends an internal error to the client.
|
||||
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error(http.StatusText(http.StatusInternalServerError),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusInternalServerError),
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// BadRequest sends a bad request error to the client.
|
||||
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Warn(http.StatusText(http.StatusBadRequest),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusBadRequest),
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Unauthorized sends a not authorized error to the client.
|
||||
func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusUnauthorized),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusUnauthorized),
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Forbidden sends a forbidden error to the client.
|
||||
func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusForbidden),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusForbidden),
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// NotFound sends a page not found error to the client.
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusNotFound),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("uri", r.RequestURI),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
),
|
||||
slog.Group("response",
|
||||
slog.Int("status_code", http.StatusNotFound),
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
func generateJSONError(err error) ([]byte, error) {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return encodedBody, nil
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package json // import "miniflux.app/v2/internal/http/response/json"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOKResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, r, map[string]string{"key": "value"})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"key":"value"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Created(w, r, map[string]string{"key": "value"})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusCreated
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"key":"value"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContentResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NoContent(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNoContent
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ServerError(w, r, errors.New("some error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"some error"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequestResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
BadRequest(w, r, errors.New("Some Error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusBadRequest
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"Some Error"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Unauthorized(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusUnauthorized
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"access unauthorized"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Forbidden(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusForbidden
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"access forbidden"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NotFound(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNotFound
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"resource not found"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvalidJSONResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, r, make(chan int))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := contentTypeHeader
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, r, map[string]string{"key": "value"})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"key":"value"}` {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{"key":"value"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONCreatedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONCreated(w, r, map[string]string{"key": "value"})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"key":"value"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"key":"value"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONAcceptedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONAccepted(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusAccepted)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONServerErrorResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONServerError(w, r, errors.New("some error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"some error"}` {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{"error_message":"some error"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONBadRequestResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONBadRequest(w, r, errors.New("Some Error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"Some Error"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"Some Error"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONUnauthorizedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONUnauthorized(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"access unauthorized"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"access unauthorized"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONForbiddenResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONForbidden(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"access forbidden"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"access forbidden"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONNotFoundResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONNotFound(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"resource not found"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"resource not found"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvalidJSONResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, r, make(chan int))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"json: unsupported type: chan int"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"json: unsupported type: chan int"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInvalidJSONCreatedResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
JSONCreated(w, r, make(chan int))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `{"error_message":"json: unsupported type: chan int"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"json: unsupported type: chan int"}`)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != jsonContentTypeHeader {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONError(t *testing.T) {
|
||||
actualBody := string(generateJSONError(errors.New("some error")))
|
||||
if actualBody != `{"error_message":"some error"}` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{"error_message":"some error"}`)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
|
||||
// default-src 'none' disables all content sources
|
||||
// form-action 'none' disables all form submissions
|
||||
@@ -12,3 +14,10 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
|
||||
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
|
||||
|
||||
// NoContent sends a no content response to the client.
|
||||
func NoContent(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNoContent).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoContentResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NoContent(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != `` {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of empty string`, actualContentType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Text writes a standard text response with a status 200 OK.
|
||||
func Text(w http.ResponseWriter, r *http.Request, body string) {
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", `text/plain; charset=utf-8`).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTextResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Text(w, r, "Some plain text")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != "Some plain text" {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, "Some plain text")
|
||||
}
|
||||
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// XML writes a standard XML response with a status 200 OK.
|
||||
func XML(w http.ResponseWriter, r *http.Request, body string) {
|
||||
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) {
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/xml; charset=utf-8").
|
||||
WithAttachment(filename).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package xml // import "miniflux.app/v2/internal/http/response/xml"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
// OK writes a standard XML response with a status 200 OK.
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithBody(body)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Attachment forces the XML document to be downloaded by the web browser.
|
||||
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithAttachment(filename)
|
||||
builder.WithBody(body)
|
||||
builder.Write()
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package xml // import "miniflux.app/v2/internal/http/response/xml"
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOKResponse(t *testing.T) {
|
||||
func TestXMLResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -18,31 +18,26 @@ func TestOKResponse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, r, "Some XML")
|
||||
XML(w, r, "Some XML")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
expectedBody := `Some XML`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
if actualBody := w.Body.String(); actualBody != "Some XML" {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "Some XML")
|
||||
}
|
||||
|
||||
expectedContentType := "text/xml; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
if actualContentType := resp.Header.Get("Content-Type"); actualContentType != "text/xml; charset=utf-8" {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, "text/xml; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentResponse(t *testing.T) {
|
||||
func TestXMLAttachmentResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -51,21 +46,18 @@ func TestAttachmentResponse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Attachment(w, r, "file.xml", "Some XML")
|
||||
XMLAttachment(w, r, "file.xml", "Some XML")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
expectedBody := `Some XML`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
if actualBody := w.Body.String(); actualBody != "Some XML" {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "Some XML")
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
@@ -74,8 +66,7 @@ func TestAttachmentResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
for header, expected := range headers {
|
||||
actual := resp.Header.Get(header)
|
||||
if actual != expected {
|
||||
if actual := resp.Header.Get(header); actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package route // import "miniflux.app/v2/internal/http/route"
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Path returns the defined route based on given arguments.
|
||||
func Path(router *mux.Router, name string, args ...any) string {
|
||||
route := router.Get(name)
|
||||
if route == nil {
|
||||
panic("route not found: " + name)
|
||||
}
|
||||
|
||||
var pairs []string
|
||||
for _, arg := range args {
|
||||
switch param := arg.(type) {
|
||||
case string:
|
||||
pairs = append(pairs, param)
|
||||
case int64:
|
||||
pairs = append(pairs, strconv.FormatInt(param, 10))
|
||||
}
|
||||
}
|
||||
|
||||
result, err := route.URLPath(pairs...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
func livenessProbe(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func newReadinessProbe(store *storage.Storage) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := store.Ping(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/api"
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/fever"
|
||||
"miniflux.app/v2/internal/googlereader"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
listenAddresses := config.Opts.ListenAddr()
|
||||
var httpServers []*http.Server
|
||||
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
certDomain := config.Opts.CertDomain()
|
||||
var sharedAutocertTLSConfig *tls.Config
|
||||
|
||||
if certDomain != "" {
|
||||
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
|
||||
sharedAutocertTLSConfig = &tls.Config{}
|
||||
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
|
||||
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
|
||||
|
||||
challengeServer := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
|
||||
go func() {
|
||||
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
httpServers = append(httpServers, challengeServer)
|
||||
}
|
||||
|
||||
for i, listenAddr := range listenAddresses {
|
||||
server := &http.Server{
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
isUNIXSocket := strings.HasPrefix(listenAddr, "/")
|
||||
isListenPID := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
|
||||
|
||||
if !isUNIXSocket && !isListenPID {
|
||||
server.Addr = listenAddr
|
||||
}
|
||||
|
||||
switch {
|
||||
case isListenPID:
|
||||
if i == 0 {
|
||||
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
|
||||
startSystemdSocketServer(server)
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.", slog.String("skipped_address", listenAddr))
|
||||
continue
|
||||
}
|
||||
case isUNIXSocket:
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
|
||||
server.Addr = listenAddr
|
||||
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
|
||||
case certFile != "" && keyFile != "":
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
|
||||
return httpServers
|
||||
}
|
||||
|
||||
func startSystemdSocketServer(server *http.Server) {
|
||||
go func() {
|
||||
f := os.NewFile(3, "systemd socket")
|
||||
listener, err := net.FileListener(f)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
|
||||
}
|
||||
|
||||
slog.Info(`Starting server using systemd socket`)
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
|
||||
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
|
||||
}
|
||||
listener, err := net.Listen("unix", socketFile)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
slog.Info("Starting TLS server using a Unix socket",
|
||||
slog.String("socket", socketFile),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
if server.TLSConfig == nil {
|
||||
server.TLSConfig = &tls.Config{}
|
||||
}
|
||||
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
|
||||
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using automatic certificate management",
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
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 {
|
||||
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startHTTPServer(server *http.Server) {
|
||||
go func() {
|
||||
slog.Info("Starting HTTP server",
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
|
||||
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := store.Ping(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// These routes do not take the base path into consideration and are always available at the root of the server.
|
||||
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
|
||||
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
|
||||
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
|
||||
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
|
||||
|
||||
var subrouter *mux.Router
|
||||
if config.Opts.BasePath() != "" {
|
||||
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
} else {
|
||||
subrouter = router.NewRoute().Subrouter()
|
||||
}
|
||||
|
||||
if config.Opts.HasMaintenanceMode() {
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(config.Opts.MaintenanceMessage()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
subrouter.Use(middleware)
|
||||
|
||||
fever.Serve(subrouter, store)
|
||||
googlereader.Serve(subrouter, store)
|
||||
if config.Opts.HasAPI() {
|
||||
api.Serve(subrouter, store, pool)
|
||||
}
|
||||
ui.Serve(subrouter, store, pool)
|
||||
|
||||
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
route := mux.CurrentRoute(r)
|
||||
|
||||
// Returns a 404 if the client is not authorized to access the metrics endpoint.
|
||||
if route.GetName() == "metrics" && !isAllowedToAccessMetricsEndpoint(r) {
|
||||
slog.Warn("Authentication failed while accessing the metrics endpoint",
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
|
||||
username, password, authOK := r.BasicAuth()
|
||||
if !authOK {
|
||||
slog.Warn("Metrics endpoint accessed without authentication header",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
slog.Warn("Metrics endpoint accessed with empty username or password",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
|
||||
slog.Warn("Metrics endpoint accessed with invalid username or password",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
remoteIP := request.FindRemoteIP(r)
|
||||
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
|
||||
}
|
||||
|
||||
func printErrorAndExit(format string, a ...any) {
|
||||
message := fmt.Sprintf(format, a...)
|
||||
slog.Error(message)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", message)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"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"
|
||||
)
|
||||
|
||||
func metricsHandler() http.Handler {
|
||||
handler := promhttp.Handler()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAllowedToAccessMetricsEndpoint(r) {
|
||||
slog.Warn("Authentication failed while accessing the metrics endpoint",
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
if config.Opts.MetricsUsername() != "" && config.Opts.MetricsPassword() != "" {
|
||||
username, password, authOK := r.BasicAuth()
|
||||
if !authOK {
|
||||
slog.Warn("Metrics endpoint accessed without authentication header",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
slog.Warn("Metrics endpoint accessed with empty username or password",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 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),
|
||||
slog.String("client_user_agent", r.UserAgent()),
|
||||
slog.String("client_remote_addr", r.RemoteAddr),
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
remoteIP := request.FindRemoteIP(r)
|
||||
return request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/api"
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/fever"
|
||||
"miniflux.app/v2/internal/googlereader"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
)
|
||||
|
||||
func newRouter(store *storage.Storage, pool *worker.Pool) http.Handler {
|
||||
readinessProbe := newReadinessProbe(store)
|
||||
|
||||
// Application routes served under the base path.
|
||||
appMux := http.NewServeMux()
|
||||
|
||||
appMux.HandleFunc("GET /healthcheck", readinessProbe)
|
||||
|
||||
// Fever API routing.
|
||||
feverHandler := fever.Middleware(store)(fever.NewHandler(store))
|
||||
appMux.Handle("/fever/", feverHandler)
|
||||
|
||||
// Google Reader API routing.
|
||||
googleReaderHandler := googlereader.NewHandler(store)
|
||||
appMux.HandleFunc("POST /accounts/ClientLogin", googleReaderHandler.ServeHTTP)
|
||||
appMux.Handle("/reader/api/0/", googleReaderHandler)
|
||||
|
||||
// REST API routing.
|
||||
if config.Opts.HasAPI() {
|
||||
appMux.Handle("/v1/", api.NewHandler(store, pool))
|
||||
}
|
||||
|
||||
// Metrics endpoint.
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
appMux.Handle("GET /metrics", metricsHandler())
|
||||
}
|
||||
|
||||
// UI routing (catch-all).
|
||||
appMux.Handle("/", ui.Serve(store, pool))
|
||||
|
||||
// Apply shared middleware.
|
||||
var appHandler http.Handler = appMux
|
||||
if config.Opts.HasMaintenanceMode() {
|
||||
appHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(config.Opts.MaintenanceMessage()))
|
||||
})
|
||||
}
|
||||
appHandler = middleware(appHandler)
|
||||
|
||||
// Root router: health probes at root, app routes under base path.
|
||||
rootMux := http.NewServeMux()
|
||||
|
||||
// These routes do not take the base path into consideration and are always available at the root of the server.
|
||||
rootMux.HandleFunc("/liveness", livenessProbe)
|
||||
rootMux.HandleFunc("/healthz", livenessProbe)
|
||||
rootMux.HandleFunc("/readiness", readinessProbe)
|
||||
rootMux.HandleFunc("/readyz", readinessProbe)
|
||||
|
||||
basePath := config.Opts.BasePath()
|
||||
if basePath != "" {
|
||||
rootMux.Handle(basePath+"/", http.StripPrefix(basePath, appHandler))
|
||||
} else {
|
||||
rootMux.Handle("/", appHandler)
|
||||
}
|
||||
|
||||
return rootMux
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
var servers []*http.Server
|
||||
|
||||
autocertTLSConfig, challengeServer := setupAutocert(store)
|
||||
if challengeServer != nil {
|
||||
servers = append(servers, challengeServer)
|
||||
}
|
||||
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
certDomain := config.Opts.CertDomain()
|
||||
|
||||
targets := determineListenTargets(config.Opts.ListenAddr(), certDomain, certFile, keyFile)
|
||||
|
||||
if autocertTLSConfig != nil || anyTLS(targets) {
|
||||
config.Opts.SetHTTPSValue(true)
|
||||
}
|
||||
|
||||
for _, t := range targets {
|
||||
srv := &http.Server{
|
||||
Addr: t.address,
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
ReadHeaderTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: newRouter(store, pool),
|
||||
}
|
||||
|
||||
switch t.mode {
|
||||
case modeSystemd:
|
||||
startSystemdSocketServer(srv)
|
||||
case modeUnixSocket:
|
||||
startUnixSocketServer(srv, t.address)
|
||||
case modeUnixSocketTLS:
|
||||
startUnixSocketTLSServer(srv, t.address, t.certFile, t.keyFile)
|
||||
case modeAutocertTLS:
|
||||
startAutoCertTLSServer(srv, autocertTLSConfig)
|
||||
case modeTLS:
|
||||
startTLSServer(srv, t.certFile, t.keyFile)
|
||||
default:
|
||||
startHTTPServer(srv)
|
||||
}
|
||||
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
type listenerMode int
|
||||
|
||||
const (
|
||||
modeHTTP listenerMode = iota
|
||||
modeTLS
|
||||
modeAutocertTLS
|
||||
modeUnixSocket
|
||||
modeUnixSocketTLS
|
||||
modeSystemd
|
||||
)
|
||||
|
||||
type listenTarget struct {
|
||||
address string
|
||||
mode listenerMode
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
|
||||
func determineListenTargets(addresses []string, certDomain, certFile, keyFile string) []listenTarget {
|
||||
isSystemd := os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid())
|
||||
hasCertFiles := certFile != "" && keyFile != ""
|
||||
hasAutocert := certDomain != ""
|
||||
|
||||
var targets []listenTarget
|
||||
|
||||
for i, addr := range addresses {
|
||||
if isSystemd {
|
||||
if i == 0 {
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeSystemd})
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: only the first listen address is used, others are ignored",
|
||||
slog.String("skipped_address", addr),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
isUnix := strings.HasPrefix(addr, "/")
|
||||
|
||||
switch {
|
||||
case isUnix && hasCertFiles:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeUnixSocketTLS, certFile: certFile, keyFile: keyFile})
|
||||
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})
|
||||
default:
|
||||
targets = append(targets, listenTarget{address: addr, mode: modeHTTP})
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
func anyTLS(targets []listenTarget) bool {
|
||||
for _, t := range targets {
|
||||
switch t.mode {
|
||||
case modeTLS, modeAutocertTLS, modeUnixSocketTLS:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setupAutocert(store *storage.Storage) (*tls.Config, *http.Server) {
|
||||
certDomain := config.Opts.CertDomain()
|
||||
if certDomain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slog.Debug("Configuring autocert manager", slog.String("domain", certDomain))
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
NextProtos: []string{"h2", "http/1.1", acme.ALPNProto},
|
||||
}
|
||||
tlsConfig.GetCertificate = certManager.GetCertificate
|
||||
|
||||
challengeServer := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
|
||||
slog.Info("Starting ACME HTTP challenge server", slog.String("address", challengeServer.Addr))
|
||||
go func() {
|
||||
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return tlsConfig, challengeServer
|
||||
}
|
||||
|
||||
func startSystemdSocketServer(server *http.Server) {
|
||||
go func() {
|
||||
f := os.NewFile(3, "systemd socket")
|
||||
listener, err := net.FileListener(f)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Unable to create listener from systemd socket: %v`, err)
|
||||
}
|
||||
|
||||
slog.Info(`Starting server using systemd socket`)
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
listener := createUnixSocketListener(socketFile)
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketTLSServer(server *http.Server, socketFile, certFile, keyFile string) {
|
||||
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 {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func createUnixSocketListener(socketFile string) net.Listener {
|
||||
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
|
||||
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
|
||||
}
|
||||
listener, err := net.Listen("unix", socketFile)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}
|
||||
|
||||
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
if server.TLSConfig == nil {
|
||||
server.TLSConfig = &tls.Config{}
|
||||
}
|
||||
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
|
||||
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using automatic certificate management",
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
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 {
|
||||
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startHTTPServer(server *http.Server) {
|
||||
go func() {
|
||||
slog.Info("Starting HTTP server",
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func printErrorAndExit(format string, a ...any) {
|
||||
message := fmt.Sprintf(format, a...)
|
||||
slog.Error(message)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", message)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetermineListenTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addresses []string
|
||||
certDomain string
|
||||
certFile string
|
||||
keyFile string
|
||||
expected []listenTarget
|
||||
}{
|
||||
{
|
||||
name: "single HTTP listener",
|
||||
addresses: []string{":8080"},
|
||||
expected: []listenTarget{
|
||||
{address: ":8080", mode: modeHTTP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple HTTP listeners",
|
||||
addresses: []string{":8080", ":9090"},
|
||||
expected: []listenTarget{
|
||||
{address: ":8080", mode: modeHTTP},
|
||||
{address: ":9090", mode: modeHTTP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TLS with cert files",
|
||||
addresses: []string{":443"},
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cert file without key file falls back to HTTP",
|
||||
addresses: []string{":8080"},
|
||||
certFile: "/path/to/cert.pem",
|
||||
expected: []listenTarget{
|
||||
{address: ":8080", mode: modeHTTP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key file without cert file falls back to HTTP",
|
||||
addresses: []string{":8080"},
|
||||
keyFile: "/path/to/key.pem",
|
||||
expected: []listenTarget{
|
||||
{address: ":8080", mode: modeHTTP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autocert with :https address",
|
||||
addresses: []string{":https"},
|
||||
certDomain: "example.com",
|
||||
expected: []listenTarget{
|
||||
{address: ":https", mode: modeAutocertTLS},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autocert with first address containing colon",
|
||||
addresses: []string{":443"},
|
||||
certDomain: "example.com",
|
||||
expected: []listenTarget{
|
||||
{address: ":443", mode: modeAutocertTLS},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autocert does not apply to second non-https address",
|
||||
addresses: []string{":https", ":8080"},
|
||||
certDomain: "example.com",
|
||||
expected: []listenTarget{
|
||||
{address: ":https", mode: modeAutocertTLS},
|
||||
{address: ":8080", mode: modeHTTP},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unix socket",
|
||||
addresses: []string{"/var/run/miniflux.sock"},
|
||||
expected: []listenTarget{
|
||||
{address: "/var/run/miniflux.sock", mode: modeUnixSocket},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unix socket with TLS",
|
||||
addresses: []string{"/var/run/miniflux.sock"},
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed unix socket and TCP",
|
||||
addresses: []string{"/var/run/miniflux.sock", ":8080"},
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty address list",
|
||||
addresses: []string{},
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := determineListenTargets(tc.addresses, tc.certDomain, tc.certFile, tc.keyFile)
|
||||
|
||||
if len(got) != len(tc.expected) {
|
||||
t.Fatalf("got %d targets, want %d", len(got), len(tc.expected))
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.expected[i] {
|
||||
t.Errorf("target[%d] = %+v, want %+v", i, got[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyTLS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
targets []listenTarget
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty list",
|
||||
targets: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP only",
|
||||
targets: []listenTarget{{mode: modeHTTP}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "systemd only",
|
||||
targets: []listenTarget{{mode: modeSystemd}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "unix socket without TLS",
|
||||
targets: []listenTarget{{mode: modeUnixSocket}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "TLS mode",
|
||||
targets: []listenTarget{{mode: modeTLS}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "autocert TLS mode",
|
||||
targets: []listenTarget{{mode: modeAutocertTLS}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unix socket TLS mode",
|
||||
targets: []listenTarget{{mode: modeUnixSocketTLS}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "mixed with one TLS",
|
||||
targets: []listenTarget{{mode: modeHTTP}, {mode: modeTLS}, {mode: modeUnixSocket}},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := anyTLS(tc.targets); got != tc.expected {
|
||||
t.Errorf("anyTLS() = %v, want %v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -25,7 +27,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
return &Client{servicesURL: serviceURL, baseURL: baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -65,7 +67,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
package archiveorg
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
|
||||
// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0
|
||||
const options = "delay_wb_availability=1&if_not_archived_within=15d"
|
||||
|
||||
@@ -18,26 +24,25 @@ func NewClient() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
func (c *Client) SendURL(entryURL, title string) {
|
||||
// We're using a goroutine here as submissions to archive.org might take a long time
|
||||
// and trigger a timeout on miniflux' side.
|
||||
go func(entryURL string) {
|
||||
res, err := http.Get("https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options)
|
||||
if err != nil {
|
||||
slog.Error("archiveorg: unable to send request: %v",
|
||||
slog.Any("err", err),
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
)
|
||||
return
|
||||
}
|
||||
if res.StatusCode > 299 {
|
||||
slog.Error("archiveorg: failed with status code",
|
||||
slog.String("title", title),
|
||||
slog.String("url", entryURL),
|
||||
slog.Int("code", res.StatusCode),
|
||||
)
|
||||
}
|
||||
res.Body.Close()
|
||||
}(entryURL)
|
||||
func (c *Client) SendURL(entryURL string) error {
|
||||
requestURL := "https://web.archive.org/save/" + url.QueryEscape(entryURL) + "?" + options
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archiveorg: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("archiveorg: unexpected status code: url=%s status=%d", requestURL, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -41,11 +43,10 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
|
||||
return fmt.Errorf("betula: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to send request: %v", err)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -77,7 +79,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -38,7 +40,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
requestBody, err := json.Marshal(&espialDocument{
|
||||
Title: entryTitle,
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
ToRead: true,
|
||||
Tags: espialTags,
|
||||
})
|
||||
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "ApiKey "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("espial: unable to send request: %v", err)
|
||||
@@ -75,7 +77,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
|
||||
|
||||
type espialDocument struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ToRead bool `json:"toread,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,9 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
request.SetBasicAuth(c.username, c.password)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instapaper: unable to send request: %v", err)
|
||||
|
||||
@@ -409,7 +409,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
archiveorg.NewClient().SendURL(entry.URL, entry.Title)
|
||||
if err := archiveorg.NewClient().SendURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Archive.org",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
@@ -447,7 +454,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
|
||||
client := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)
|
||||
if err := client.SaveUrl(entry.URL); err != nil {
|
||||
if err := client.SaveURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Omnivore",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
@@ -543,7 +550,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Warn("Unable to send new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
@@ -642,7 +649,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
client := pushover.NewClient(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
@@ -658,30 +665,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if err := telegrambot.PushEntry(
|
||||
feed,
|
||||
entry,
|
||||
userIntegrations.TelegramBotToken,
|
||||
userIntegrations.TelegramBotChatID,
|
||||
userIntegrations.TelegramBotTopicID,
|
||||
userIntegrations.TelegramBotDisableWebPagePreview,
|
||||
userIntegrations.TelegramBotDisableNotification,
|
||||
userIntegrations.TelegramBotDisableButtons,
|
||||
); err != nil {
|
||||
slog.Error("Unable to send entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
|
||||
if err := telegrambot.PushEntry(
|
||||
feed,
|
||||
entry,
|
||||
userIntegrations.TelegramBotToken,
|
||||
userIntegrations.TelegramBotChatID,
|
||||
userIntegrations.TelegramBotTopicID,
|
||||
userIntegrations.TelegramBotDisableWebPagePreview,
|
||||
userIntegrations.TelegramBotDisableNotification,
|
||||
userIntegrations.TelegramBotDisableButtons,
|
||||
); err != nil {
|
||||
slog.Error("Unable to send entry to Telegram",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,7 @@ type errorResponse struct {
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
|
||||
}
|
||||
|
||||
func (c *Client) attachTags(entryID string) error {
|
||||
@@ -56,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})
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Private: c.private,
|
||||
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: unable to send request: %v", err)
|
||||
@@ -80,7 +82,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
|
||||
type createItemRequest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Private bool `json:"is_private,omitempty"`
|
||||
CheckDisabled bool `json:"check_disabled,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkdingBookmark{
|
||||
Url: entryURL,
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
|
||||
Unread: c.unread,
|
||||
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Token "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkding: unable to send request: %v", err)
|
||||
@@ -78,7 +80,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
}
|
||||
|
||||
type linkdingBookmark struct {
|
||||
Url string `json:"url,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
TagNames []string `json:"tag_names,omitempty"`
|
||||
Unread bool `json:"unread,omitempty"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linktaco: unable to send request: %v", err)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestCreateBookmark(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
@@ -50,7 +54,7 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Parse and verify request
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
@@ -62,9 +66,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
|
||||
// Return success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Article",
|
||||
@@ -111,9 +115,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "Invalid input",
|
||||
},
|
||||
},
|
||||
@@ -145,9 +149,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errors": []interface{}{
|
||||
map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []any{
|
||||
map[string]any{
|
||||
"message": "PRIVATE visibility requires a paid LinkTaco account",
|
||||
},
|
||||
},
|
||||
@@ -165,12 +169,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that description was truncated
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
description := input["description"].(string)
|
||||
|
||||
if len(description) != maxDescriptionLength {
|
||||
@@ -178,9 +182,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -196,12 +200,12 @@ func TestCreateBookmark(t *testing.T) {
|
||||
entryContent: "Content",
|
||||
serverResponse: func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
// Check that only 10 tags were sent
|
||||
variables := req["variables"].(map[string]interface{})
|
||||
input := variables["input"].(map[string]interface{})
|
||||
variables := req["variables"].(map[string]any)
|
||||
input := variables["input"].(map[string]any)
|
||||
tags := input["tags"].(string)
|
||||
|
||||
tagCount := len(strings.Split(tags, ","))
|
||||
@@ -210,9 +214,9 @@ func TestCreateBookmark(t *testing.T) {
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{"id": "123"},
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{"id": "123"},
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -323,10 +327,12 @@ func TestNewClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGraphQLMutation(t *testing.T) {
|
||||
configureIntegrationAllowPrivateNetworksOption(t)
|
||||
|
||||
// Test that the GraphQL mutation is properly formatted
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]interface{}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("Failed to parse request: %v", err)
|
||||
}
|
||||
@@ -349,12 +355,12 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify variables structure
|
||||
variables, ok := req["variables"].(map[string]interface{})
|
||||
variables, ok := req["variables"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing variables field")
|
||||
}
|
||||
|
||||
input, ok := variables["input"].(map[string]interface{})
|
||||
input, ok := variables["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Missing input in variables")
|
||||
}
|
||||
@@ -369,9 +375,9 @@ func TestGraphQLMutation(t *testing.T) {
|
||||
|
||||
// Return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -397,9 +403,9 @@ func BenchmarkCreateBookmark(b *testing.B) {
|
||||
// Create a mock server that always returns success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"addLink": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addLink": map[string]any{
|
||||
"id": "123",
|
||||
},
|
||||
},
|
||||
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
|
||||
_ = strings.Join(splitTags, ",")
|
||||
}
|
||||
}
|
||||
|
||||
func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
|
||||
|
||||
configParser := config.NewConfigParser()
|
||||
parsedOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to configure test options: %v", err)
|
||||
}
|
||||
|
||||
previousOptions := config.Opts
|
||||
config.Opts = parsedOptions
|
||||
t.Cleanup(func() {
|
||||
config.Opts = previousOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/client"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkwarden: unable to send request: %v", err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user