Compare commits
501 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 | |||
| d3a1e7dbec | |||
| 7edf42fe18 | |||
| 5513827cdd | |||
| 052ad27e38 | |||
| 96c45874f5 | |||
| 3e022fb86e | |||
| aa814491f6 | |||
| 90c2199f0f | |||
| d40e523d25 | |||
| 46e77eccb0 | |||
| f19fc2ff53 | |||
| 0201c80db1 | |||
| 0022d0646b | |||
| 253119ed5f | |||
| 2619eba43c | |||
| 31f4f366a9 | |||
| fc9cb9e5d5 | |||
| 9b859e7f3f | |||
| 6f3c92db74 | |||
| 0dcc0a852b | |||
| 7b65255757 | |||
| 6a9d7894d1 | |||
| f3d0e0378b | |||
| 4d27f66ff9 | |||
| 5a8431b144 | |||
| 9e760e14cd | |||
| 879e40670c | |||
| bf24fd1ec9 | |||
| b57ebac757 | |||
| acfb7dc213 | |||
| 62f316c7cf | |||
| 89638b448a | |||
| d8181b1f65 | |||
| ce6cd0b2b6 | |||
| ba05c96d88 | |||
| 58674b9277 | |||
| 6b1f208209 | |||
| 7c41128d88 | |||
| 3aea68e725 | |||
| a5fb91e3e8 | |||
| 8357e6decf | |||
| ad4ca71f68 | |||
| 660c79bbdd | |||
| 2fa8995a35 | |||
| ffed2b4fa1 | |||
| 9fc69fd2e0 | |||
| cddd2eb168 | |||
| 33b780d9c0 | |||
| 5d8540b324 | |||
| a2a5244387 | |||
| 3a232d0c8d | |||
| 5a8df8abf0 | |||
| a4883ca11e | |||
| 4cd66aef5f | |||
| 0ab3bb5efb | |||
| 71c551ffe0 | |||
| bb05b25530 | |||
| 7b7fd48e15 | |||
| fb0fcfb266 | |||
| 39840bb0d2 | |||
| 79a3369699 | |||
| 696b98bb16 | |||
| 85fa69ba38 | |||
| c5a9111d58 | |||
| a46afe6979 | |||
| 2f67a68e75 | |||
| 83ca211cfb | |||
| 55aa15dd52 | |||
| e54e6c097c | |||
| 56a11d1d45 | |||
| df28028b24 | |||
| 6439d352d2 | |||
| a33cfb2b7d | |||
| 590adb9967 | |||
| 9e817e646f | |||
| 5594586941 | |||
| 5db66fc279 | |||
| 561389da69 | |||
| cd7d61966c | |||
| 29f6dc8896 | |||
| 6c83e8c477 | |||
| 29015903ba | |||
| 709e671168 | |||
| 473c9f225e | |||
| 90a389ac25 | |||
| 6106546a32 | |||
| ad82191ce1 | |||
| 6080d56a5c | |||
| 854985bcfa | |||
| 1bf7c0bb51 | |||
| bc409480e7 | |||
| 3cb74e152b | |||
| fbc4d700d5 | |||
| 40d9965b28 | |||
| 28e670d989 | |||
| dc12713be1 | |||
| 7bf58e7ab0 | |||
| c502b80fa9 | |||
| c23a62184b | |||
| 327119c828 | |||
| 488a3bba5f | |||
| 7f3c6db4d8 | |||
| 884569fc76 | |||
| 0960624e85 | |||
| 4063ca39c9 | |||
| f6476db767 | |||
| 142d8d7679 | |||
| 5bd5993a24 | |||
| 18cd544fab | |||
| 88f9f279e6 | |||
| dc1653ba3a | |||
| 3ea4aee4d6 | |||
| f447307359 | |||
| 4892e04e9b | |||
| 579b1efc68 | |||
| a16aa10fed | |||
| ea9adc8978 | |||
| fd7c886997 | |||
| b3a63dbd86 | |||
| 7ac8eb96c4 | |||
| c43a90716b | |||
| 0a08f4832e | |||
| bc375e3c59 | |||
| d3ebfd67dd | |||
| c1cfd184ee | |||
| e580126b8d | |||
| 86fca5d060 | |||
| c365118cf7 | |||
| 6f2d10c436 | |||
| 093004b724 | |||
| d2c783b06c | |||
| 7e2dd3afe6 | |||
| 4ace959667 | |||
| 14f31954d1 | |||
| 907daf78bf | |||
| cf6386b471 | |||
| 76df99f3a3 | |||
| 57e3eaecd9 | |||
| 21d3a5a61c | |||
| 385d8bb969 | |||
| c171da1734 | |||
| 11ea137027 | |||
| 0a15075c00 | |||
| b1fda599ac | |||
| 509b7682ad | |||
| 06c2e50ffa | |||
| ec93656ef5 | |||
| 317aaeeec7 | |||
| d862f79123 | |||
| 1bea41b19c | |||
| 65a39096e7 | |||
| 131dc674e4 | |||
| b0dced42c0 | |||
| a34e33b5c5 | |||
| af12fe309e | |||
| dd44fbcc76 | |||
| 9c956d1b0d | |||
| bf7f55e28a | |||
| b8bc367a00 | |||
| 04a360a536 | |||
| fac18d5c57 | |||
| a3d1ecc58a | |||
| 8adcaed29e | |||
| 5a97bf8b5e | |||
| e279b955c4 | |||
| 1620f8d3f2 | |||
| 1a29c1568c | |||
| ff07f02716 | |||
| 79b0d0b9cc | |||
| 5fa0709663 | |||
| a7fa2ecc8c | |||
| 10b2b36895 | |||
| 87e65f800e | |||
| 907cf16af1 | |||
| 854a78a7d1 | |||
| 5e607be86a | |||
| 502e7108dd | |||
| 0b93d8abcc | |||
| 93a8629910 | |||
| b1742168e1 | |||
| 4eff9129ab | |||
| eb22d90b56 | |||
| 8f2dd02f3f | |||
| da8d4d86c3 | |||
| 7ada5d54be | |||
| 8129500296 | |||
| f1143151c5 | |||
| baf8e40152 | |||
| a98374b81f | |||
| 77e0b07f9f | |||
| 645800ce3c | |||
| a60a153003 | |||
| 7d40e2993b | |||
| 91a625c559 | |||
| dbb3855901 | |||
| 3051acf369 | |||
| 5f38054965 | |||
| fa361ab1ce | |||
| afe80e6bae | |||
| f2976bff5d | |||
| 84078c7c20 | |||
| d70817e441 | |||
| e8f5c2446c | |||
| 737f25f441 | |||
| f83d66769d | |||
| af149e46df | |||
| 4f252b33c9 | |||
| da9c3a4032 | |||
| 8e1d0bb693 | |||
| 60cd7ffe88 | |||
| 4d656d2739 | |||
| 983291c78b | |||
| 7060ecc163 | |||
| 4af12a4129 | |||
| c6536e8d90 | |||
| 30453ad7ec | |||
| 71af68becd | |||
| ed3bf59356 | |||
| 03021af53c | |||
| 83254a1f26 | |||
| 3acb888309 | |||
| 7c29166ef9 | |||
| 71105bec56 | |||
| 2a372674a2 | |||
| 9d32b23ab0 | |||
| da8bf3890c | |||
| b30eac4ebe |
@@ -9,7 +9,9 @@
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -28,4 +30,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
@@ -11,10 +10,10 @@ services:
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: miniflux2
|
||||
|
||||
+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:
|
||||
- "*"
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
name: Build Binaries
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
workflow_dispatch:
|
||||
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
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
@@ -21,7 +32,8 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Codeberg
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
delete:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
env:
|
||||
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
|
||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
|
||||
run: |
|
||||
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
|
||||
git push --force --prune codeberg \
|
||||
"refs/heads/*:refs/heads/*" \
|
||||
"refs/tags/*:refs/tags/*"
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
@@ -16,12 +17,14 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '45 22 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -30,20 +33,27 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
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@v5
|
||||
- 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@v5
|
||||
- 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@v4
|
||||
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@v5
|
||||
- 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,21 +9,23 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
- '.github/workflows/docker.yml'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
if: github.repository_owner == 'miniflux'
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
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
|
||||
@@ -36,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
|
||||
@@ -50,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@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,16 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
args: >
|
||||
--timeout 10m
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,whitespace,gocritic
|
||||
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -43,11 +38,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -11,17 +11,19 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
- '.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@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
run: make rpm VERSION=2.2.x_dev
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
build-package-manually:
|
||||
@@ -29,24 +31,24 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
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@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Match
|
||||
|
||||
# Conventional commit pattern (including Git revert messages)
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = (
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
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@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: standard
|
||||
disable:
|
||||
- errcheck
|
||||
enable:
|
||||
- errname
|
||||
- gocritic
|
||||
- goheader
|
||||
- loggercheck
|
||||
- misspell
|
||||
- perfsprint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- whitespace
|
||||
settings:
|
||||
loggercheck:
|
||||
slog: true
|
||||
goheader:
|
||||
template: |-
|
||||
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
+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,8 +103,8 @@ test:
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
test -z "$$(gofmt -l .)"
|
||||
golangci-lint run
|
||||
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_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
|
||||
|
||||
@@ -72,7 +72,7 @@ Features
|
||||
|
||||
### Integrations
|
||||
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- Bookmarklet for subscribing to websites directly from any web browser.
|
||||
- Webhooks for real-time notifications or custom integrations.
|
||||
- Compatibility with existing mobile applications using the Fever or Google Reader API.
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ Miniflux API Client
|
||||
|
||||
[](https://pkg.go.dev/miniflux.app/v2/client)
|
||||
|
||||
Client library for Miniflux REST API.
|
||||
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
+552
-71
@@ -4,9 +4,11 @@
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -20,28 +22,53 @@ 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...)
|
||||
}
|
||||
|
||||
// NewClient returns a new Miniflux client.
|
||||
func NewClient(endpoint string, credentials ...string) *Client {
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
|
||||
case 1:
|
||||
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
|
||||
default:
|
||||
return NewClientWithOptions(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithOptions returns a new Miniflux client with options.
|
||||
func NewClientWithOptions(endpoint string, options ...Option) *Client {
|
||||
// Trim trailing slashes and /v1 from the endpoint.
|
||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
||||
case 1:
|
||||
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
||||
default:
|
||||
return &Client{request: &request{endpoint: endpoint}}
|
||||
request := &request{endpoint: endpoint, client: http.DefaultClient}
|
||||
|
||||
for _, option := range options {
|
||||
option(request)
|
||||
}
|
||||
|
||||
return &Client{request: request}
|
||||
}
|
||||
|
||||
func withDefaultTimeout() (context.Context, func()) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
// Healthcheck checks if the application is up and running.
|
||||
func (c *Client) Healthcheck() error {
|
||||
body, err := c.request.Get("/healthcheck")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.HealthcheckContext(ctx)
|
||||
}
|
||||
|
||||
// HealthcheckContext checks if the application is up and running.
|
||||
func (c *Client) HealthcheckContext(ctx context.Context) error {
|
||||
body, err := c.request.Get(ctx, "/healthcheck")
|
||||
if err != nil {
|
||||
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
|
||||
}
|
||||
@@ -61,7 +88,14 @@ func (c *Client) Healthcheck() error {
|
||||
|
||||
// Version returns the version of the Miniflux instance.
|
||||
func (c *Client) Version() (*VersionResponse, error) {
|
||||
body, err := c.request.Get("/v1/version")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.VersionContext(ctx)
|
||||
}
|
||||
|
||||
// VersionContext returns the version of the Miniflux instance.
|
||||
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,7 +111,14 @@ func (c *Client) Version() (*VersionResponse, error) {
|
||||
|
||||
// Me returns the logged user information.
|
||||
func (c *Client) Me() (*User, error) {
|
||||
body, err := c.request.Get("/v1/me")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MeContext(ctx)
|
||||
}
|
||||
|
||||
// MeContext returns the logged user information.
|
||||
func (c *Client) MeContext(ctx context.Context) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/me")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +134,14 @@ func (c *Client) Me() (*User, error) {
|
||||
|
||||
// Users returns all users.
|
||||
func (c *Client) Users() (Users, error) {
|
||||
body, err := c.request.Get("/v1/users")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UsersContext(ctx)
|
||||
}
|
||||
|
||||
// UsersContext returns all users.
|
||||
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +157,14 @@ func (c *Client) Users() (Users, error) {
|
||||
|
||||
// UserByID returns a single user.
|
||||
func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByIDContext(ctx, userID)
|
||||
}
|
||||
|
||||
// UserByIDContext returns a single user.
|
||||
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,7 +180,14 @@ func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
|
||||
// UserByUsername returns a single user.
|
||||
func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%s", username))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByUsernameContext(ctx, username)
|
||||
}
|
||||
|
||||
// UserByUsernameContext returns a single user.
|
||||
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users/"+username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +203,14 @@ func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
|
||||
// CreateUser creates a new user in the system.
|
||||
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post("/v1/users", &UserCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateUserContext(ctx, username, password, isAdmin)
|
||||
}
|
||||
|
||||
// CreateUserContext creates a new user in the system.
|
||||
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
IsAdmin: isAdmin,
|
||||
@@ -161,7 +230,14 @@ func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, err
|
||||
|
||||
// UpdateUser updates a user in the system.
|
||||
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateUserContext(ctx, userID, userChanges)
|
||||
}
|
||||
|
||||
// UpdateUserContext updates a user in the system.
|
||||
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,12 +253,26 @@ func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest)
|
||||
|
||||
// DeleteUser removes a user from the system.
|
||||
func (c *Client) DeleteUser(userID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteUserContext(ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteUserContext removes a user from the system.
|
||||
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
}
|
||||
|
||||
// APIKeys returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeys() (APIKeys, error) {
|
||||
body, err := c.request.Get("/v1/api-keys")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.APIKeysContext(ctx)
|
||||
}
|
||||
|
||||
// APIKeysContext returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/api-keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -198,7 +288,14 @@ func (c *Client) APIKeys() (APIKeys, error) {
|
||||
|
||||
// CreateAPIKey creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
|
||||
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateAPIKeyContext(ctx, description)
|
||||
}
|
||||
|
||||
// CreateAPIKeyContext creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/api-keys", &APIKeyCreationRequest{
|
||||
Description: description,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -216,18 +313,39 @@ func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
|
||||
|
||||
// DeleteAPIKey removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteAPIKeyContext(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyContext removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsRead(userID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkAllAsReadContext(ctx, userID)
|
||||
}
|
||||
|
||||
// MarkAllAsReadContext marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
// IntegrationsStatus fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
body, err := c.request.Get("/v1/integrations/status")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IntegrationsStatusContext(ctx)
|
||||
}
|
||||
|
||||
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/integrations/status")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -244,9 +362,16 @@ func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
// Discover tries to find subscriptions on a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DiscoverContext(ctx, url)
|
||||
}
|
||||
|
||||
// DiscoverContext tries to find subscriptions from a website.
|
||||
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -260,9 +385,16 @@ func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Categories gets the list of categories.
|
||||
// Categories retrieves the list of categories.
|
||||
func (c *Client) Categories() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesContext retrieves the list of categories.
|
||||
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -278,7 +410,14 @@ func (c *Client) Categories() (Categories, error) {
|
||||
|
||||
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories?counts=true")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesWithCountersContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,7 +433,14 @@ func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
|
||||
// CreateCategory creates a new category.
|
||||
func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryContext(ctx, title)
|
||||
}
|
||||
|
||||
// CreateCategoryContext creates a new category.
|
||||
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -312,7 +458,14 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
|
||||
// CreateCategoryWithOptions creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", createRequest)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
|
||||
}
|
||||
|
||||
// CreateCategoryWithOptionsContext creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/categories", createRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -327,8 +480,15 @@ func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationReques
|
||||
|
||||
// UpdateCategory updates a category.
|
||||
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: SetOptionalField(title),
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryContext(ctx, categoryID, title)
|
||||
}
|
||||
|
||||
// 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: new(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -345,7 +505,14 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
|
||||
// UpdateCategoryWithOptions updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
|
||||
}
|
||||
|
||||
// UpdateCategoryWithOptionsContext updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -361,13 +528,27 @@ func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *Ca
|
||||
|
||||
// MarkCategoryAsRead marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkCategoryAsReadContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// MarkCategoryAsReadContext marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
// CategoryFeeds returns all feeds for a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryFeedsContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// CategoryFeedsContext returns all feeds for a category.
|
||||
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,18 +564,39 @@ func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
|
||||
// DeleteCategory removes a category.
|
||||
func (c *Client) DeleteCategory(categoryID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// DeleteCategoryContext removes a category.
|
||||
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
}
|
||||
|
||||
// RefreshCategory refreshes a category.
|
||||
func (c *Client) RefreshCategory(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// RefreshCategoryContext refreshes a category.
|
||||
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feeds gets all feeds.
|
||||
func (c *Client) Feeds() (Feeds, error) {
|
||||
body, err := c.request.Get("/v1/feeds")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedsContext(ctx)
|
||||
}
|
||||
|
||||
// FeedsContext gets all feeds.
|
||||
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -408,9 +610,16 @@ func (c *Client) Feeds() (Feeds, error) {
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// Export creates OPML file.
|
||||
// Export exports subscriptions as an OPML document.
|
||||
func (c *Client) Export() ([]byte, error) {
|
||||
body, err := c.request.Get("/v1/export")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ExportContext(ctx)
|
||||
}
|
||||
|
||||
// ExportContext exports subscriptions as an OPML document.
|
||||
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/export")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -426,13 +635,27 @@ func (c *Client) Export() ([]byte, error) {
|
||||
|
||||
// Import imports an OPML file.
|
||||
func (c *Client) Import(f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile("/v1/import", f)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ImportContext(ctx, f)
|
||||
}
|
||||
|
||||
// ImportContext imports an OPML file.
|
||||
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile(ctx, "/v1/import", f)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed gets a feed.
|
||||
func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedContext gets a feed.
|
||||
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,7 +671,14 @@ func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
|
||||
// CreateFeed creates a new feed.
|
||||
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post("/v1/feeds", feedCreationRequest)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateFeedContext(ctx, feedCreationRequest)
|
||||
}
|
||||
|
||||
// CreateFeedContext creates a new feed.
|
||||
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -468,7 +698,14 @@ func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, er
|
||||
|
||||
// UpdateFeed updates a feed.
|
||||
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateFeedContext(ctx, feedID, feedChanges)
|
||||
}
|
||||
|
||||
// UpdateFeedContext updates a feed.
|
||||
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -482,32 +719,93 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ImportFeedEntry imports a single entry into a feed.
|
||||
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
|
||||
body, err := c.request.Post(
|
||||
ctx,
|
||||
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||
return 0, fmt.Errorf("miniflux: json error (%v)", err)
|
||||
}
|
||||
|
||||
return response.ID, nil
|
||||
}
|
||||
|
||||
// MarkFeedAsRead marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsRead(feedID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkFeedAsReadContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// MarkFeedAsReadContext marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshAllFeeds refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeeds() error {
|
||||
_, err := c.request.Put("/v1/feeds/refresh", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshAllFeedsContext(ctx)
|
||||
}
|
||||
|
||||
// RefreshAllFeedsContext refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshFeed refreshes a feed.
|
||||
func (c *Client) RefreshFeed(feedID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// RefreshFeedContext refreshes a feed.
|
||||
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFeed removes a feed.
|
||||
func (c *Client) DeleteFeed(feedID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// DeleteFeedContext removes a feed.
|
||||
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
}
|
||||
|
||||
// FeedIcon gets a feed icon.
|
||||
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedIconContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedIconContext gets a feed icon.
|
||||
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -523,7 +821,14 @@ func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
|
||||
// FeedEntry gets a single feed entry.
|
||||
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedEntryContext(ctx, feedID, entryID)
|
||||
}
|
||||
|
||||
// FeedEntryContext gets a single feed entry.
|
||||
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -539,7 +844,14 @@ func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
|
||||
// CategoryEntry gets a single category entry.
|
||||
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryEntryContext(ctx, categoryID, entryID)
|
||||
}
|
||||
|
||||
// CategoryEntryContext gets a single category entry.
|
||||
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -555,7 +867,14 @@ func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
|
||||
// Entry gets a single entry.
|
||||
func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// EntryContext gets a single entry.
|
||||
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -569,11 +888,67 @@ func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Entries fetch entries.
|
||||
// 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()
|
||||
defer cancel()
|
||||
return c.EntriesContext(ctx, filter)
|
||||
}
|
||||
|
||||
// EntriesContext fetches entries.
|
||||
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString("/v1/entries", filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -587,11 +962,18 @@ func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FeedEntries fetch feed entries.
|
||||
// FeedEntries fetches entries for a feed using the given filter.
|
||||
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedEntriesContext(ctx, feedID, filter)
|
||||
}
|
||||
|
||||
// FeedEntriesContext fetches feed entries.
|
||||
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -605,11 +987,18 @@ func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, err
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CategoryEntries fetch entries of a category.
|
||||
// CategoryEntries fetches entries for a category using the given filter.
|
||||
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryEntriesContext(ctx, categoryID, filter)
|
||||
}
|
||||
|
||||
// CategoryEntriesContext fetches category entries.
|
||||
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -625,18 +1014,50 @@ func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResult
|
||||
|
||||
// UpdateEntries updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntriesContext(ctx, entryIDs, status)
|
||||
}
|
||||
|
||||
// UpdateEntriesContext updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
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) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntryContext(ctx, entryID, entryChanges)
|
||||
}
|
||||
|
||||
// UpdateEntryContext updates an entry.
|
||||
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -650,21 +1071,42 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (c *Client) ToggleBookmark(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
// ToggleStarred toggles the starred flag of an entry.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ToggleStarredContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// ToggleStarredContext toggles entry starred value.
|
||||
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveEntry sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntry(entryID int64) error {
|
||||
_, err := c.request.Post(fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.SaveEntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// SaveEntryContext sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchEntryOriginalContentContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -683,7 +1125,14 @@ func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
|
||||
// FetchCounters fetches feed counters.
|
||||
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
body, err := c.request.Get("/v1/feeds/counters")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchCountersContext(ctx)
|
||||
}
|
||||
|
||||
// FetchCountersContext fetches feed counters.
|
||||
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds/counters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -697,15 +1146,29 @@ func (c *Client) FetchCounters() (*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 {
|
||||
_, err := c.request.Put("/v1/flush-history", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FlushHistoryContext(ctx)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Icon fetches a feed icon.
|
||||
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IconContext(ctx, iconID)
|
||||
}
|
||||
|
||||
// IconContext fetches a feed icon.
|
||||
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -721,7 +1184,14 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
|
||||
// Enclosure fetches a specific enclosure.
|
||||
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EnclosureContext(ctx, enclosureID)
|
||||
}
|
||||
|
||||
// EnclosureContext fetches a specific enclosure.
|
||||
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -737,7 +1207,14 @@ func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
|
||||
// UpdateEnclosure updates an enclosure.
|
||||
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
|
||||
}
|
||||
|
||||
// UpdateEnclosureContext updates an enclosure.
|
||||
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -821,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())
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
|
||||
|
||||
# Examples
|
||||
|
||||
This code snippet fetch the list of users:
|
||||
This example fetches the list of users:
|
||||
|
||||
import (
|
||||
miniflux "miniflux.app/v2/client"
|
||||
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
|
||||
}
|
||||
fmt.Println(users, err)
|
||||
|
||||
This one discover subscriptions on a website:
|
||||
This example discovers subscriptions on a website:
|
||||
|
||||
subscriptions, err := client.Discover("https://example.org/")
|
||||
if err != nil {
|
||||
|
||||
+39
-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"`
|
||||
@@ -356,6 +385,9 @@ type APIKeyCreationRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Option func(*request)
|
||||
|
||||
// WithAPIKey sets the API key for the client.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(r *request) {
|
||||
r.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredentials sets the username and password for the client.
|
||||
func WithCredentials(username, password string) Option {
|
||||
return func(r *request) {
|
||||
r.username = username
|
||||
r.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the client.
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(r *request) {
|
||||
r.client = client
|
||||
}
|
||||
}
|
||||
+25
-23
@@ -5,6 +5,7 @@ package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
userAgent = "Miniflux Client Library"
|
||||
defaultTimeout = 80
|
||||
defaultTimeout = 80 * time.Second
|
||||
)
|
||||
|
||||
// List of exposed errors.
|
||||
@@ -39,30 +40,36 @@ type request struct {
|
||||
username string
|
||||
password string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodGet, path, nil)
|
||||
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func (r *request) Post(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, data)
|
||||
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, data)
|
||||
}
|
||||
|
||||
func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, f)
|
||||
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, f)
|
||||
}
|
||||
|
||||
func (r *request) Put(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
func (r *request) Delete(path string) error {
|
||||
_, err := r.execute(http.MethodDelete, path, nil)
|
||||
func (r *request) Delete(ctx context.Context, path string) error {
|
||||
_, err := r.execute(ctx, http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data any) (io.ReadCloser, error) {
|
||||
func (r *request) execute(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
path string,
|
||||
data any,
|
||||
) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
@@ -75,12 +82,13 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := &http.Request{
|
||||
URL: u,
|
||||
Method: method,
|
||||
Header: r.buildHeaders(),
|
||||
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header = r.buildHeaders()
|
||||
|
||||
if r.username != "" && r.password != "" {
|
||||
request.SetBasicAuth(r.username, r.password)
|
||||
}
|
||||
@@ -94,7 +102,7 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
}
|
||||
}
|
||||
|
||||
client := r.buildClient()
|
||||
client := r.client
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,12 +151,6 @@ func (r *request) execute(method, path string, data any) (io.ReadCloser, error)
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *request) buildHeaders() http.Header {
|
||||
headers := make(http.Header)
|
||||
headers.Add("User-Agent", userAgent)
|
||||
|
||||
@@ -21,12 +21,13 @@ services:
|
||||
db:
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
- POSTGRES_DB=miniflux
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,48 +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.10.3
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/tdewolff/minify/v2 v2.23.11
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.34.0
|
||||
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.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.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
)
|
||||
|
||||
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.0.5 // 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/mitchellh/mapstructure v1.5.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.65.0 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // 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
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // 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,64 +1,76 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
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.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/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=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
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.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
|
||||
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||
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=
|
||||
@@ -66,16 +78,20 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
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=
|
||||
@@ -90,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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
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=
|
||||
@@ -112,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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=
|
||||
@@ -123,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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
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=
|
||||
@@ -134,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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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=
|
||||
@@ -143,7 +159,10 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+60
-76
@@ -5,90 +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/{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.toggleBookmark).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)))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
miniflux "miniflux.app/v2/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
|
||||
@@ -579,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)
|
||||
@@ -608,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)
|
||||
@@ -637,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 {
|
||||
@@ -661,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)
|
||||
@@ -690,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)
|
||||
@@ -720,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)
|
||||
@@ -1089,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)
|
||||
@@ -1108,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)
|
||||
@@ -1127,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" {
|
||||
@@ -1611,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)
|
||||
@@ -1652,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 {
|
||||
@@ -1684,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 {
|
||||
@@ -2381,7 +2382,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2631,7 +2631,7 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
|
||||
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2659,33 +2659,272 @@ func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
// First we set the entry as "removed"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
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(result.Entries[0].ID)
|
||||
entry, err := regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`Expected entry to be starred`)
|
||||
}
|
||||
|
||||
// Then we try to set it to "unread"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
|
||||
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(result.Entries[0].ID)
|
||||
entry, err = regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It should stay set to "removed"
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -2749,7 +2988,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
func TestToggleStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2777,7 +3016,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
|
||||
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2787,7 +3026,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`The entry should be bookmarked`)
|
||||
t.Fatalf(`The entry should be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2932,13 +3171,56 @@ func TestFlushHistoryEndpoint(t *testing.T) {
|
||||
if readEntries.Total != 0 {
|
||||
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
|
||||
func TestImportFeedEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
client := miniflux.NewClient(
|
||||
testConfig.testBaseURL,
|
||||
testConfig.testAdminUsername,
|
||||
testConfig.testAdminPassword,
|
||||
)
|
||||
|
||||
// Create a feed
|
||||
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.DeleteFeed(feedID)
|
||||
|
||||
payload := map[string]any{
|
||||
"title": "Imported Entry",
|
||||
"url": "https://example.org/imported-entry",
|
||||
"content": "Hello world",
|
||||
"external_id": "integration-test-entry-1",
|
||||
"status": model.EntryStatusUnread,
|
||||
"starred": false,
|
||||
"published_at": 0,
|
||||
}
|
||||
|
||||
// First import
|
||||
firstID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if removedEntries.Total != 2 {
|
||||
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
|
||||
if firstID == 0 {
|
||||
t.Fatal("expected non-zero entry ID on first import")
|
||||
}
|
||||
|
||||
// Second import (same payload)
|
||||
secondID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if secondID != firstID {
|
||||
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,402 +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/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/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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleBookmark(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
|
||||
}
|
||||
|
||||
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) 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, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
}
|
||||
|
||||
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,53 +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 !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
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(),
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
type feedIconResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type entriesResponse struct {
|
||||
Total int `json:"total"`
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
type integrationsStatusResponse struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type entryImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Status string `json:"status"`
|
||||
Starred bool `json:"starred"`
|
||||
Tags []string `json:"tags"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
type importFeedsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Compiler string `json:"compiler"`
|
||||
Arch string `json:"arch"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
+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, map[string]string{"message": "Feeds imported successfully"})
|
||||
response.JSONCreated(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
}
|
||||
@@ -1,33 +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 (
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
type feedIconResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type entriesResponse struct {
|
||||
Total int `json:"total"`
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Compiler string `json:"compiler"`
|
||||
Arch string `json:"arch"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
@@ -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,222 +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)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -16,20 +17,34 @@ func askCredentials() (string, string) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
|
||||
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("\n")
|
||||
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,15 +14,16 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
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.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive read entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving read entries completed",
|
||||
@@ -35,7 +36,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
|
||||
startTime = time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive unread entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving unread entries completed",
|
||||
@@ -47,17 +48,11 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-36
@@ -4,7 +4,6 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -30,9 +29,9 @@ const (
|
||||
flagDebugModeHelp = "Show debug logs"
|
||||
flagConfigFileHelp = "Load configuration file"
|
||||
flagConfigDumpHelp = "Print parsed configuration values"
|
||||
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
|
||||
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
|
||||
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
|
||||
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
|
||||
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
|
||||
)
|
||||
@@ -78,7 +77,7 @@ func Parse() {
|
||||
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewParser()
|
||||
cfg := config.NewConfigParser()
|
||||
|
||||
if flagConfigFile != "" {
|
||||
config.Opts, err = cfg.ParseFile(flagConfigFile)
|
||||
@@ -92,21 +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 err := config.Opts.Validate(); err != nil {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
@@ -114,6 +100,16 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
}
|
||||
|
||||
if flagVersion {
|
||||
fmt.Println(version.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if flagDebugMode {
|
||||
config.Opts.SetLogLevel("debug")
|
||||
}
|
||||
@@ -128,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()
|
||||
}
|
||||
@@ -142,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(
|
||||
@@ -175,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()
|
||||
|
||||
@@ -245,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +249,11 @@ func Parse() {
|
||||
}
|
||||
|
||||
func printErrorAndExit(err error) {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
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()),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -19,7 +20,7 @@ func resetPassword(store *storage.Storage) {
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
printErrorAndExit(fmt.Errorf("user not found"))
|
||||
printErrorAndExit(errors.New("user not found"))
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
|
||||
+13
-12
@@ -26,21 +26,22 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
|
||||
go cleanupScheduler(
|
||||
store,
|
||||
config.Opts.CleanupFrequencyHours(),
|
||||
config.Opts.CleanupFrequency(),
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
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()))
|
||||
@@ -49,8 +50,8 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupScheduler(store *storage.Storage, frequency int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Hour) {
|
||||
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
|
||||
for range time.Tick(frequency) {
|
||||
runCleanupTasks(store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "miniflux.app/v2/internal/version"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *configOptions
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
File diff suppressed because it is too large
Load Diff
+898
-686
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+242
-255
@@ -10,307 +10,259 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parser handles configuration parsing.
|
||||
type parser struct {
|
||||
opts *options
|
||||
type configParser struct {
|
||||
options *configOptions
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *parser {
|
||||
return &parser{
|
||||
opts: NewOptions(),
|
||||
func NewConfigParser() *configParser {
|
||||
return &configParser{
|
||||
options: NewConfigOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *parser) ParseEnvironmentVariables() (*options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
|
||||
if err := cp.parseLines(os.Environ()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *parser) ParseFile(filename string) (*options, error) {
|
||||
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
err = p.parseLines(p.parseFileContent(fp))
|
||||
if err != nil {
|
||||
if err := cp.parseLines(parseFileContent(fp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (p *parser) parseLines(lines []string) (err error) {
|
||||
var port string
|
||||
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
p.opts.logFile = parseString(value, defaultLogFile)
|
||||
case "LOG_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "LOG_LEVEL":
|
||||
parsedValue := parseString(value, defaultLogLevel)
|
||||
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
|
||||
p.opts.logLevel = parsedValue
|
||||
}
|
||||
case "LOG_FORMAT":
|
||||
parsedValue := parseString(value, defaultLogFormat)
|
||||
if parsedValue == "json" || parsedValue == "text" {
|
||||
p.opts.logFormat = parsedValue
|
||||
}
|
||||
case "BASE_URL":
|
||||
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
|
||||
case "DATABASE_MAX_CONNS":
|
||||
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
|
||||
case "DATABASE_MIN_CONNS":
|
||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||
case "DATABASE_CONNECTION_LIFETIME":
|
||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||
case "FILTER_ENTRY_MAX_AGE_DAYS":
|
||||
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
|
||||
case "RUN_MIGRATIONS":
|
||||
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
||||
case "DISABLE_HSTS":
|
||||
p.opts.hsts = !parseBool(value, defaultHSTS)
|
||||
case "HTTPS":
|
||||
p.opts.HTTPS = parseBool(value, defaultHTTPS)
|
||||
case "DISABLE_SCHEDULER_SERVICE":
|
||||
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
|
||||
case "DISABLE_HTTP_SERVICE":
|
||||
p.opts.httpService = !parseBool(value, defaultHTTPService)
|
||||
case "CERT_FILE":
|
||||
p.opts.certFile = parseString(value, defaultCertFile)
|
||||
case "KEY_FILE":
|
||||
p.opts.certKeyFile = parseString(value, defaultKeyFile)
|
||||
case "CERT_DOMAIN":
|
||||
p.opts.certDomain = parseString(value, defaultCertDomain)
|
||||
case "CLEANUP_FREQUENCY_HOURS":
|
||||
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
|
||||
case "CLEANUP_ARCHIVE_READ_DAYS":
|
||||
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
|
||||
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
|
||||
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
|
||||
case "CLEANUP_ARCHIVE_BATCH_SIZE":
|
||||
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
|
||||
case "CLEANUP_REMOVE_SESSIONS_DAYS":
|
||||
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
|
||||
case "WORKER_POOL_SIZE":
|
||||
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
|
||||
case "FORCE_REFRESH_INTERVAL":
|
||||
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
|
||||
case "BATCH_SIZE":
|
||||
p.opts.batchSize = parseInt(value, defaultBatchSize)
|
||||
case "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "POLLING_LIMIT_PER_HOST":
|
||||
p.opts.pollingLimitPerHost = parseInt(value, 0)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
|
||||
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
|
||||
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
|
||||
}
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
p.opts.adminUsername = parseString(value, defaultAdminUsername)
|
||||
case "ADMIN_USERNAME_FILE":
|
||||
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
|
||||
case "ADMIN_PASSWORD":
|
||||
p.opts.adminPassword = parseString(value, defaultAdminPassword)
|
||||
case "ADMIN_PASSWORD_FILE":
|
||||
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
|
||||
case "OAUTH2_USER_CREATION":
|
||||
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
|
||||
case "OAUTH2_CLIENT_ID":
|
||||
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_ID_FILE":
|
||||
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_SECRET":
|
||||
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_CLIENT_SECRET_FILE":
|
||||
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_REDIRECT_URL":
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
|
||||
case "HTTP_CLIENT_PROXY":
|
||||
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
|
||||
}
|
||||
case "HTTP_CLIENT_PROXIES":
|
||||
p.opts.httpClientProxies = parseStringList(value, []string{})
|
||||
case "HTTP_CLIENT_USER_AGENT":
|
||||
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
|
||||
case "HTTP_SERVER_TIMEOUT":
|
||||
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
|
||||
case "AUTH_PROXY_HEADER":
|
||||
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
|
||||
case "AUTH_PROXY_USER_CREATION":
|
||||
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
|
||||
case "MAINTENANCE_MODE":
|
||||
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
|
||||
case "MAINTENANCE_MESSAGE":
|
||||
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
|
||||
case "METRICS_COLLECTOR":
|
||||
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
|
||||
case "METRICS_REFRESH_INTERVAL":
|
||||
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
|
||||
case "METRICS_ALLOWED_NETWORKS":
|
||||
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
|
||||
case "METRICS_USERNAME":
|
||||
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
|
||||
case "METRICS_USERNAME_FILE":
|
||||
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
|
||||
case "METRICS_PASSWORD":
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||
case "INVIDIOUS_INSTANCE":
|
||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||
case "WEBAUTHN":
|
||||
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
||||
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 port != "" {
|
||||
p.opts.listenAddr = []string{":" + port}
|
||||
if c.AuthProxyHeader() != "" && len(c.TrustedReverseProxyNetworks()) == 0 {
|
||||
return errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used")
|
||||
}
|
||||
|
||||
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
|
||||
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")
|
||||
}
|
||||
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].parsedStringValue
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
value = strings.TrimSuffix(value, "/")
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
return fmt.Errorf("invalid BASE_URL: %v", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
return errors.New("BASE_URL scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := parsedURL.Path
|
||||
cp.options.options["BASE_URL"].parsedStringValue = baseURL
|
||||
cp.options.basePath = parsedURL.Path
|
||||
|
||||
parsedURL.Path = ""
|
||||
return value, parsedURL.String(), basePath, nil
|
||||
cp.options.rootURL = parsedURL.String()
|
||||
|
||||
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
|
||||
if youTubeEmbedURLOverride != "" {
|
||||
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
|
||||
}
|
||||
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
|
||||
}
|
||||
|
||||
// Generate a media proxy private key if not set
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
|
||||
}
|
||||
|
||||
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
|
||||
if cp.options.Port() != "" {
|
||||
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(value string, fallback bool) bool {
|
||||
func (cp *configParser) parseLines(lines []string) error {
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
if err := cp.parseLine(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cp.postParsing(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
|
||||
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
|
||||
}
|
||||
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the option if a validator is provided
|
||||
if field.validator != nil {
|
||||
if err := field.validator(value); err != nil {
|
||||
return fmt.Errorf("invalid value for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the raw value based on its type
|
||||
switch field.valueType {
|
||||
case stringType:
|
||||
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
|
||||
field.rawValue = value
|
||||
case stringListType:
|
||||
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
|
||||
field.rawValue = value
|
||||
case boolType:
|
||||
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedBoolValue = parsedValue
|
||||
field.rawValue = value
|
||||
case intType:
|
||||
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
|
||||
field.rawValue = value
|
||||
case int64Type:
|
||||
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
|
||||
field.rawValue = value
|
||||
case secondType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case minuteType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case hourType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case dayType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case urlType:
|
||||
parsedURL, err := parseURLValue(value, field.parsedURLValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedURLValue = parsedURL
|
||||
field.rawValue = value
|
||||
case secretFileType:
|
||||
secretValue, err := readSecretFileValue(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
|
||||
}
|
||||
if field.targetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.targetKey]; ok {
|
||||
targetField.parsedStringValue = secretValue
|
||||
targetField.rawValue = secretValue
|
||||
}
|
||||
}
|
||||
field.rawValue = value
|
||||
case bytesType:
|
||||
if value != "" {
|
||||
field.parsedBytesValue = []byte(value)
|
||||
field.rawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStringValue(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBoolValue(value string, fallback bool) (bool, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
if value == "0" || value == "no" || value == "false" || value == "off" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false
|
||||
return false, fmt.Errorf("invalid boolean value: %q", value)
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
func parseIntValue(value string, fallback int) int {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -323,14 +275,20 @@ func parseInt(value string, fallback int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func parseString(value string, fallback string) string {
|
||||
func ParsedInt64Value(value string, fallback int64) int64 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
func parseStringListValue(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -350,24 +308,53 @@ func parseStringList(value string, fallback []string) []string {
|
||||
return strList
|
||||
}
|
||||
|
||||
func parseBytes(value string, fallback []byte) []byte {
|
||||
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func readSecretFile(filename, fallback string) string {
|
||||
data, err := os.ReadFile(filename)
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
return time.Duration(v) * unit
|
||||
}
|
||||
|
||||
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
|
||||
if value == "" {
|
||||
return fallback
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return value
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
func readSecretFileValue(filename string) (string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
if value == "" {
|
||||
return "", errors.New("secret file is empty")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
+406
-137
@@ -4,163 +4,432 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"": true,
|
||||
"1": true,
|
||||
"Yes": true,
|
||||
"yes": true,
|
||||
"True": true,
|
||||
"true": true,
|
||||
"on": true,
|
||||
"false": false,
|
||||
"off": false,
|
||||
"invalid": false,
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
// Test with non-empty value
|
||||
result := parseStringValue("test", "fallback")
|
||||
if result != "test" {
|
||||
t.Errorf("Expected 'test', got '%s'", result)
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
result := parseBool(input, true)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
|
||||
// Test with empty value
|
||||
result = parseStringValue("", "fallback")
|
||||
if result != "fallback" {
|
||||
t.Errorf("Expected 'fallback', got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with empty value and empty fallback
|
||||
result = parseStringValue("", "")
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty string, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result, err := parseBoolValue("", true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true, got %v", result)
|
||||
}
|
||||
|
||||
// Test true values
|
||||
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
|
||||
for _, value := range trueValues {
|
||||
result, err := parseBoolValue(value, false)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValueWithUnsetVariable(t *testing.T) {
|
||||
if parseString("", "defaultValue") != "defaultValue" {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
// Test false values
|
||||
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
|
||||
for _, value := range falseValues {
|
||||
result, err := parseBoolValue(value, true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != false {
|
||||
t.Errorf("Expected false for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
if parseString("test", "defaultValue") != "test" {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithUnsetVariable(t *testing.T) {
|
||||
if parseInt("", 42) != 42 {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithInvalidInput(t *testing.T) {
|
||||
if parseInt("invalid integer", 42) != 42 {
|
||||
t.Errorf(`Invalid integer should returns the default value`)
|
||||
// Test invalid value - should return error
|
||||
_, err = parseBoolValue("invalid", false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValue(t *testing.T) {
|
||||
if parseInt("2018", 42) != 2018 {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
// Test with empty value - should return fallback
|
||||
result := parseIntValue("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid integer
|
||||
result = parseIntValue("123", 42)
|
||||
if result != 123 {
|
||||
t.Errorf("Expected 123, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid integer - should return fallback
|
||||
result = parseIntValue("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with zero
|
||||
result = parseIntValue("0", 42)
|
||||
if result != 0 {
|
||||
t.Errorf("Expected 0, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListenAddr(t *testing.T) {
|
||||
defaultExpected := []string{defaultListenAddr}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listenAddr string
|
||||
port string
|
||||
expected []string
|
||||
lines []string // Used for direct lines parsing instead of individual env vars
|
||||
isLineOriented bool // Flag to indicate if we use lines
|
||||
}{
|
||||
{
|
||||
name: "Single LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8080",
|
||||
expected: []string{"127.0.0.1:8080"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR comma-separated",
|
||||
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
|
||||
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR with spaces around commas",
|
||||
listenAddr: "127.0.0.1:8080 , :8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
expected: defaultExpected,
|
||||
},
|
||||
{
|
||||
name: "PORT overrides LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8000",
|
||||
port: "8082",
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "PORT overrides empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
port: "8083",
|
||||
expected: []string{":8083"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR with empty segment (comma)",
|
||||
listenAddr: "127.0.0.1:8080,,:8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "PORT override with lines parsing",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR only with lines parsing (comma)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
|
||||
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR with lines parsing (default)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR="},
|
||||
expected: defaultExpected,
|
||||
},
|
||||
func TestParsedInt64Value(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result := ParsedInt64Value("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewParser()
|
||||
var err error
|
||||
// Test with valid int64
|
||||
result = ParsedInt64Value("9223372036854775807", 42)
|
||||
if result != 9223372036854775807 {
|
||||
t.Errorf("Expected 9223372036854775807, got %d", result)
|
||||
}
|
||||
|
||||
if tt.isLineOriented {
|
||||
err = parser.parseLines(tt.lines)
|
||||
} else {
|
||||
// Simulate os.Environ() behaviour for individual var testing
|
||||
var envLines []string
|
||||
if tt.listenAddr != "" {
|
||||
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
|
||||
}
|
||||
if tt.port != "" {
|
||||
envLines = append(envLines, "PORT="+tt.port)
|
||||
}
|
||||
// Add a dummy var if both are empty to avoid empty lines slice if not intended
|
||||
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
|
||||
// This case specifically tests empty LISTEN_ADDR resulting in default
|
||||
// So, we pass LISTEN_ADDR=
|
||||
envLines = append(envLines, "LISTEN_ADDR=")
|
||||
}
|
||||
err = parser.parseLines(envLines)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("parseLines() error = %v", err)
|
||||
}
|
||||
|
||||
opts := parser.opts
|
||||
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
|
||||
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
|
||||
}
|
||||
})
|
||||
// Test with invalid int64 - should return fallback
|
||||
result = ParsedInt64Value("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := []string{"a", "b"}
|
||||
result := parseStringListValue("", fallback)
|
||||
if !reflect.DeepEqual(result, fallback) {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with single value
|
||||
result = parseStringListValue("item1", nil)
|
||||
expected := []string{"item1"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple values
|
||||
result = parseStringListValue("item1,item2,item3", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with duplicates - should remove duplicates
|
||||
result = parseStringListValue("item1,item2,item1", nil)
|
||||
expected = []string{"item1", "item2"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with spaces
|
||||
result = parseStringListValue(" item1 , item2 , item3 ", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := 5 * time.Second
|
||||
result := parseDurationValue("", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with valid duration
|
||||
result = parseDurationValue("30", time.Second, fallback)
|
||||
expected := 30 * time.Second
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with minutes
|
||||
result = parseDurationValue("5", time.Minute, fallback)
|
||||
expected = 5 * time.Minute
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid value - should return fallback
|
||||
result = parseDurationValue("invalid", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallbackURL, _ := url.Parse("https://fallback.com")
|
||||
result, err := parseURLValue("", fallbackURL)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected %v, got %v", fallbackURL, result)
|
||||
}
|
||||
|
||||
// Test with valid URL
|
||||
result, err = parseURLValue("https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result.String() != "https://example.com" {
|
||||
t.Errorf("Expected https://example.com, got %s", result.String())
|
||||
}
|
||||
|
||||
// Test with invalid URL - should return fallback and error
|
||||
result, err = parseURLValue("://invalid", fallbackURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected fallback URL, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsing(t *testing.T) {
|
||||
fileContent := `
|
||||
# This is a comment
|
||||
LOG_FILE=miniflux.log
|
||||
LOG_DATE_TIME=1
|
||||
LOG_FORMAT=json
|
||||
LISTEN_ADDR=:8080,:8443
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
|
||||
if configOptions.LogFormat() != "json" {
|
||||
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
|
||||
}
|
||||
|
||||
if configOptions.LogLevel() != "info" {
|
||||
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
|
||||
}
|
||||
|
||||
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
|
||||
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
|
||||
fileContent := `
|
||||
LOG_FILE=miniflux.log
|
||||
INVALID_LINE
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal("Invalid lines should be ignored, but got error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOption(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
password := "supersecret"
|
||||
if _, err := tmpFile.WriteString(password); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.AdminPassword() != password {
|
||||
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error due to empty password file, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "miniflux.log")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "true")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "invalid")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatal("Expected parsing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateChoices(rawValue string, choices []string) error {
|
||||
if !slices.Contains(choices, rawValue) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListChoices(inputValues, choices []string) error {
|
||||
for _, value := range inputValues {
|
||||
if err := validateChoices(value, choices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGreaterThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue > min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be at least %d", min)
|
||||
}
|
||||
|
||||
func validateGreaterOrEqualThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue >= min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be greater or equal than %d", min)
|
||||
}
|
||||
|
||||
func validateRange(rawValue string, min, max int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue < min || intValue > max {
|
||||
return fmt.Errorf("value must be between %d and %d", min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid choice",
|
||||
rawValue: "option1",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from middle",
|
||||
rawValue: "option2",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from end",
|
||||
rawValue: "option3",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid choice",
|
||||
rawValue: "invalid",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty value with non-empty choices",
|
||||
rawValue: "",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
rawValue: "OPTION1",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "single choice valid",
|
||||
rawValue: "only",
|
||||
choices: []string{"only"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty choices list",
|
||||
rawValue: "anything",
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateChoices(tt.rawValue, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValues []string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid choices",
|
||||
inputValues: []string{"option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single valid choice",
|
||||
inputValues: []string{"option1"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty input list",
|
||||
inputValues: []string{},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "all choices from available list",
|
||||
inputValues: []string{"option1", "option2", "option3"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate valid choices",
|
||||
inputValues: []string{"option1", "option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "one invalid choice",
|
||||
inputValues: []string{"option1", "invalid"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "all invalid choices",
|
||||
inputValues: []string{"invalid1", "invalid2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
inputValues: []string{"OPTION1"},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string in input",
|
||||
inputValues: []string{""},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty choices list with non-empty input",
|
||||
inputValues: []string{"anything"},
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid choices",
|
||||
inputValues: []string{"option1", "invalid", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListChoices(tt.inputValues, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterThan(t *testing.T) {
|
||||
if err := validateGreaterThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("5", 5); err == nil {
|
||||
t.Errorf("expected error, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterOrEqualThan(t *testing.T) {
|
||||
if err := validateGreaterOrEqualThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("5", 5); err != nil {
|
||||
t.Errorf("expected no error for equal value, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
min int
|
||||
max int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid integer within range",
|
||||
rawValue: "5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at minimum",
|
||||
rawValue: "1",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at maximum",
|
||||
rawValue: "10",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid zero in range",
|
||||
rawValue: "0",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid negative in range",
|
||||
rawValue: "-3",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "integer below minimum",
|
||||
rawValue: "0",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer above maximum",
|
||||
rawValue: "11",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far below minimum",
|
||||
rawValue: "-100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far above maximum",
|
||||
rawValue: "100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "non-integer string",
|
||||
rawValue: "abc",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
rawValue: "",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "float string",
|
||||
rawValue: "5.5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "string with spaces",
|
||||
rawValue: " 5 ",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "single value range",
|
||||
rawValue: "5",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single value range - below",
|
||||
rawValue: "4",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
{
|
||||
name: "single value range - above",
|
||||
rawValue: "6",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRange(tt.rawValue, tt.min, tt.max)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+431
-21
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
var migrations = [...]func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
@@ -20,7 +21,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
username text not null unique,
|
||||
password text,
|
||||
is_admin bool default 'f',
|
||||
@@ -32,7 +33,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null,
|
||||
token text not null unique,
|
||||
created_at timestamp with time zone default now(),
|
||||
@@ -44,7 +45,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE categories (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null,
|
||||
title text not null,
|
||||
primary key (id),
|
||||
@@ -53,7 +54,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE feeds (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
category_id int not null,
|
||||
title text not null,
|
||||
@@ -73,7 +74,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
|
||||
|
||||
CREATE TABLE entries (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
feed_id bigint not null,
|
||||
hash text not null,
|
||||
@@ -92,7 +93,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
|
||||
|
||||
CREATE TABLE enclosures (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
user_id int not null,
|
||||
entry_id bigint not null,
|
||||
url text not null,
|
||||
@@ -104,7 +105,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
);
|
||||
|
||||
CREATE TABLE icons (
|
||||
id bigserial not null,
|
||||
id BIGSERIAL,
|
||||
hash text not null unique,
|
||||
mime_type text not null,
|
||||
content bytea not null,
|
||||
@@ -123,13 +124,9 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
ALTER TABLE users ADD COLUMN extra hstore;
|
||||
CREATE INDEX users_extra_idx ON users using gin(extra);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
// This used to create a HSTORE `extra` column in the table `users`,
|
||||
// which hasn't been used since Miniflux 2.0.27.
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
@@ -334,7 +331,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE api_keys (
|
||||
id serial not null,
|
||||
id SERIAL,
|
||||
user_id int not null references users(id) on delete cascade,
|
||||
token text not null unique,
|
||||
description text not null,
|
||||
@@ -356,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
|
||||
},
|
||||
@@ -436,6 +437,18 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
|
||||
hasExtra := false
|
||||
if err := tx.QueryRow(`
|
||||
SELECT true
|
||||
FROM information_schema.columns
|
||||
WHERE
|
||||
table_name='users' AND
|
||||
column_name='extra';
|
||||
`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD column stylesheet text not null default '',
|
||||
@@ -446,6 +459,11 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasExtra {
|
||||
// No need to migrate things from the `extra` column if it's not present
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
@@ -470,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
|
||||
@@ -495,7 +513,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
@@ -710,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
|
||||
}
|
||||
@@ -1068,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
|
||||
@@ -1135,4 +1158,391 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE linktaco_link_visibility AS ENUM (
|
||||
'PUBLIC',
|
||||
'PRIVATE'
|
||||
);
|
||||
ALTER TABLE integrations
|
||||
ADD COLUMN linktaco_enabled bool default 'f',
|
||||
ADD COLUMN linktaco_api_token text default '',
|
||||
ADD COLUMN linktaco_org_slug text default '',
|
||||
ADD COLUMN linktaco_tags text default '',
|
||||
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
|
||||
func(tx *sql.Tx) (err error) {
|
||||
var deprecatedTimeZoneMap = map[string]string{
|
||||
// Africa
|
||||
"Africa/Asmera": "Africa/Asmara",
|
||||
|
||||
// America - Argentina
|
||||
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
|
||||
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
|
||||
"America/Catamarca": "America/Argentina/Catamarca",
|
||||
"America/Cordoba": "America/Argentina/Cordoba",
|
||||
"America/Jujuy": "America/Argentina/Jujuy",
|
||||
"America/Mendoza": "America/Argentina/Mendoza",
|
||||
"America/Rosario": "America/Argentina/Cordoba",
|
||||
|
||||
// America - US
|
||||
"America/Fort_Wayne": "America/Indiana/Indianapolis",
|
||||
"America/Indianapolis": "America/Indiana/Indianapolis",
|
||||
"America/Knox_IN": "America/Indiana/Knox",
|
||||
"America/Louisville": "America/Kentucky/Louisville",
|
||||
|
||||
// America - Greenland
|
||||
"America/Godthab": "America/Nuuk",
|
||||
|
||||
// Antarctica
|
||||
"Antarctica/South_Pole": "Pacific/Auckland",
|
||||
|
||||
// Asia
|
||||
"Asia/Ashkhabad": "Asia/Ashgabat",
|
||||
"Asia/Calcutta": "Asia/Kolkata",
|
||||
"Asia/Choibalsan": "Asia/Ulaanbaatar",
|
||||
"Asia/Chungking": "Asia/Chongqing",
|
||||
"Asia/Dacca": "Asia/Dhaka",
|
||||
"Asia/Katmandu": "Asia/Kathmandu",
|
||||
"Asia/Macao": "Asia/Macau",
|
||||
"Asia/Rangoon": "Asia/Yangon",
|
||||
"Asia/Saigon": "Asia/Ho_Chi_Minh",
|
||||
"Asia/Thimbu": "Asia/Thimphu",
|
||||
"Asia/Ujung_Pandang": "Asia/Makassar",
|
||||
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
|
||||
|
||||
// Atlantic
|
||||
"Atlantic/Faeroe": "Atlantic/Faroe",
|
||||
|
||||
// Australia
|
||||
"Australia/ACT": "Australia/Sydney",
|
||||
"Australia/LHI": "Australia/Lord_Howe",
|
||||
"Australia/North": "Australia/Darwin",
|
||||
"Australia/NSW": "Australia/Sydney",
|
||||
"Australia/Queensland": "Australia/Brisbane",
|
||||
"Australia/South": "Australia/Adelaide",
|
||||
"Australia/Tasmania": "Australia/Hobart",
|
||||
"Australia/Victoria": "Australia/Melbourne",
|
||||
"Australia/West": "Australia/Perth",
|
||||
|
||||
// Brazil
|
||||
"Brazil/Acre": "America/Rio_Branco",
|
||||
"Brazil/DeNoronha": "America/Noronha",
|
||||
"Brazil/East": "America/Sao_Paulo",
|
||||
"Brazil/West": "America/Manaus",
|
||||
|
||||
// Canada
|
||||
"Canada/Atlantic": "America/Halifax",
|
||||
"Canada/Central": "America/Winnipeg",
|
||||
"Canada/Eastern": "America/Toronto",
|
||||
"Canada/Mountain": "America/Edmonton",
|
||||
"Canada/Newfoundland": "America/St_Johns",
|
||||
"Canada/Pacific": "America/Vancouver",
|
||||
"Canada/Saskatchewan": "America/Regina",
|
||||
"Canada/Yukon": "America/Whitehorse",
|
||||
|
||||
// Europe
|
||||
"CET": "Europe/Paris",
|
||||
"EET": "Europe/Sofia",
|
||||
"Europe/Kiev": "Europe/Kyiv",
|
||||
"Europe/Uzhgorod": "Europe/Kyiv",
|
||||
"Europe/Zaporozhye": "Europe/Kyiv",
|
||||
"MET": "Europe/Paris",
|
||||
"WET": "Europe/Lisbon",
|
||||
|
||||
// Chile
|
||||
"Chile/Continental": "America/Santiago",
|
||||
"Chile/EasterIsland": "Pacific/Easter",
|
||||
|
||||
// Fixed offset and generic zones
|
||||
"CST6CDT": "America/Chicago",
|
||||
"EST": "America/New_York",
|
||||
"EST5EDT": "America/New_York",
|
||||
"HST": "Pacific/Honolulu",
|
||||
"MST": "America/Denver",
|
||||
"MST7MDT": "America/Denver",
|
||||
"PST8PDT": "America/Los_Angeles",
|
||||
|
||||
// Countries/Regions
|
||||
"Cuba": "America/Havana",
|
||||
"Egypt": "Africa/Cairo",
|
||||
"Eire": "Europe/Dublin",
|
||||
"GB": "Europe/London",
|
||||
"GB-Eire": "Europe/London",
|
||||
"Hongkong": "Asia/Hong_Kong",
|
||||
"Iceland": "Atlantic/Reykjavik",
|
||||
"Iran": "Asia/Tehran",
|
||||
"Israel": "Asia/Jerusalem",
|
||||
"Jamaica": "America/Jamaica",
|
||||
"Japan": "Asia/Tokyo",
|
||||
"Libya": "Africa/Tripoli",
|
||||
"Poland": "Europe/Warsaw",
|
||||
"Portugal": "Europe/Lisbon",
|
||||
"PRC": "Asia/Shanghai",
|
||||
"ROC": "Asia/Taipei",
|
||||
"ROK": "Asia/Seoul",
|
||||
"Singapore": "Asia/Singapore",
|
||||
"Turkey": "Europe/Istanbul",
|
||||
|
||||
// GMT variations
|
||||
"GMT+0": "GMT",
|
||||
"GMT-0": "GMT",
|
||||
"GMT0": "GMT",
|
||||
"Greenwich": "GMT",
|
||||
"UCT": "UTC",
|
||||
"Universal": "UTC",
|
||||
"Zulu": "UTC",
|
||||
|
||||
// Mexico
|
||||
"Mexico/BajaNorte": "America/Tijuana",
|
||||
"Mexico/BajaSur": "America/Mazatlan",
|
||||
"Mexico/General": "America/Mexico_City",
|
||||
|
||||
// US zones
|
||||
"Navajo": "America/Denver",
|
||||
"US/Alaska": "America/Anchorage",
|
||||
"US/Aleutian": "America/Adak",
|
||||
"US/Arizona": "America/Phoenix",
|
||||
"US/Central": "America/Chicago",
|
||||
"US/Eastern": "America/New_York",
|
||||
"US/East-Indiana": "America/Indiana/Indianapolis",
|
||||
"US/Hawaii": "Pacific/Honolulu",
|
||||
"US/Indiana-Starke": "America/Indiana/Knox",
|
||||
"US/Michigan": "America/Detroit",
|
||||
"US/Mountain": "America/Denver",
|
||||
"US/Pacific": "America/Los_Angeles",
|
||||
"US/Samoa": "Pacific/Pago_Pago",
|
||||
|
||||
// Pacific
|
||||
"Kwajalein": "Pacific/Kwajalein",
|
||||
"NZ": "Pacific/Auckland",
|
||||
"NZ-CHAT": "Pacific/Chatham",
|
||||
"Pacific/Enderbury": "Pacific/Kanton",
|
||||
"Pacific/Ponape": "Pacific/Pohnpei",
|
||||
"Pacific/Truk": "Pacific/Chuuk",
|
||||
|
||||
// Special cases
|
||||
"Factory": "UTC", // Factory is used for unconfigured systems
|
||||
"W-SU": "Europe/Moscow",
|
||||
}
|
||||
|
||||
// Loop through each user and correct the timezone
|
||||
rows, err := tx.Query(`SELECT id, timezone FROM users`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userTimezoneMap := make(map[int64]string)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var userTimezone string
|
||||
if err := rows.Scan(&userID, &userTimezone); err != nil {
|
||||
return err
|
||||
}
|
||||
userTimezoneMap[userID] = userTimezone
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for userID, userTimezone := range userTimezoneMap {
|
||||
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
|
||||
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `DROP EXTENSION IF EXISTS hstore;`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// There is no need to keep an index on the content of deleted entries.
|
||||
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sql := `
|
||||
CREATE INDEX document_vectors_idx
|
||||
ON entries
|
||||
USING gin(document_vectors)
|
||||
WHERE status != 'removed';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE user_sessions
|
||||
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
|
||||
ALTER COLUMN ip SET NOT NULL,
|
||||
ALTER COLUMN created_at SET DEFAULT now(),
|
||||
ALTER COLUMN created_at SET NOT NULL,
|
||||
ALTER COLUMN user_agent SET DEFAULT '',
|
||||
ALTER COLUMN user_agent SET NOT NULL
|
||||
`)
|
||||
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'
|
||||
```
|
||||
+98
-119
@@ -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,14 +132,14 @@ 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
|
||||
}
|
||||
|
||||
var result feedsResponse
|
||||
result.Feeds = make([]feed, 0)
|
||||
result.Feeds = make([]feed, 0, len(feeds))
|
||||
for _, f := range feeds {
|
||||
subscripion := feed{
|
||||
subscription := feed{
|
||||
ID: f.ID,
|
||||
Title: f.Title,
|
||||
URL: f.FeedURL,
|
||||
@@ -155,15 +149,15 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if f.Icon != nil {
|
||||
subscripion.FaviconID = f.Icon.IconID
|
||||
subscription.FaviconID = f.Icon.IconID
|
||||
}
|
||||
|
||||
result.Feeds = append(result.Feeds, subscripion)
|
||||
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,27 +258,27 @@ 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", "")
|
||||
if csvItemIDs != "" {
|
||||
var itemIDs []int64
|
||||
|
||||
for _, strItemID := range strings.Split(csvItemIDs, ",") {
|
||||
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
|
||||
strItemID = strings.TrimSpace(strItemID)
|
||||
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder = builder.WithEntryIDs(itemIDs...)
|
||||
}
|
||||
default:
|
||||
slog.Debug("[Fever] Fetching oldest items",
|
||||
@@ -295,19 +288,18 @@ 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
|
||||
}
|
||||
|
||||
result.Items = make([]item, 0)
|
||||
result.Items = make([]item, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
isRead := 0
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
@@ -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,21 +336,21 @@ 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
|
||||
}
|
||||
|
||||
var itemIDs []string
|
||||
itemIDs := make([]string, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
@@ -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,29 +369,28 @@ 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
|
||||
}
|
||||
|
||||
var itemsIDs []string
|
||||
itemsIDs := make([]string, 0, len(entryIDs))
|
||||
for _, entryID := range entryIDs {
|
||||
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -455,14 +444,14 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -474,13 +463,13 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
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,13 +546,13 @@ 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 {
|
||||
feedsGroupedByCategory := make(map[int64][]string)
|
||||
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))
|
||||
}
|
||||
|
||||
result := make([]feedsGroups, 0)
|
||||
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
|
||||
for categoryID, feedIDs := range feedsGroupedByCategory {
|
||||
result = append(result, feedsGroups{
|
||||
GroupID: categoryID,
|
||||
|
||||
@@ -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`
|
||||
+336
-380
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -58,7 +59,7 @@ func parseItemID(itemIDValue string) (int64, error) {
|
||||
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
|
||||
items := r.Form[paramItemIDs]
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("googlereader: no items requested")
|
||||
return nil, errors.New("googlereader: no items requested")
|
||||
}
|
||||
|
||||
itemIDs := make([]int64, len(items))
|
||||
|
||||
+129
-137
@@ -6,189 +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) handleCORS(next http.Handler) http.Handler {
|
||||
func (m *authMiddleware) validateApiKey(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", "Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
m.serveValidated(w, r, next)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
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)
|
||||
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",
|
||||
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)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if expectedToken != token {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
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)
|
||||
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]
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
)
|
||||
|
||||
type RequestModifiers struct {
|
||||
type requestModifiers struct {
|
||||
ExcludeTargets []Stream
|
||||
FilterTargets []Stream
|
||||
Streams []Stream
|
||||
@@ -24,24 +24,24 @@ type RequestModifiers struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (r RequestModifiers) String() string {
|
||||
func (r requestModifiers) String() string {
|
||||
var results []string
|
||||
|
||||
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
|
||||
|
||||
var streamStr []string
|
||||
streamStr := make([]string, 0, len(r.Streams))
|
||||
for _, s := range r.Streams {
|
||||
streamStr = append(streamStr, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
|
||||
|
||||
var exclusions []string
|
||||
exclusions := make([]string, 0, len(r.ExcludeTargets))
|
||||
for _, s := range r.ExcludeTargets {
|
||||
exclusions = append(exclusions, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
|
||||
|
||||
var filters []string
|
||||
filters := make([]string, 0, len(r.FilterTargets))
|
||||
for _, s := range r.FilterTargets {
|
||||
filters = append(filters, s.String())
|
||||
}
|
||||
@@ -49,17 +49,17 @@ func (r RequestModifiers) String() string {
|
||||
|
||||
results = append(results, fmt.Sprintf("Count: %d", r.Count))
|
||||
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
|
||||
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
|
||||
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
|
||||
results = append(results, "Sort Direction: "+r.SortDirection)
|
||||
results = append(results, "Continuation Token: "+r.ContinuationToken)
|
||||
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
|
||||
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
|
||||
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
func parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {
|
||||
userID := request.UserID(r)
|
||||
result := RequestModifiers{
|
||||
result := requestModifiers{
|
||||
SortDirection: "desc",
|
||||
UserID: userID,
|
||||
}
|
||||
@@ -71,16 +71,16 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
return requestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
|
||||
|
||||
@@ -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(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
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
|
||||
}
|
||||
@@ -9,19 +9,46 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
|
||||
func FindClientIP(r *http.Request) string {
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
|
||||
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
|
||||
if len(trustedNetworks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
ip := net.ParseIP(remoteIP)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
for _, cidr := range trustedNetworks {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
|
||||
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
|
||||
if isTrustedProxyClient {
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,19 +57,35 @@ func FindClientIP(r *http.Request) string {
|
||||
return FindRemoteIP(r)
|
||||
}
|
||||
|
||||
// FindRemoteIP returns remote client IP address without considering HTTP headers.
|
||||
// FindRemoteIP returns the parsed remote IP address from the request,
|
||||
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
|
||||
func FindRemoteIP(r *http.Request) string {
|
||||
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
remoteIP = r.RemoteAddr
|
||||
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return dropIPv6zone(remoteIP)
|
||||
|
||||
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
// Strip IPv6 zone identifier if present (e.g., %eth0).
|
||||
ip = dropIPv6zone(ip)
|
||||
|
||||
// Validate the IP address.
|
||||
if net.ParseIP(ip) == nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func dropIPv6zone(address string) string {
|
||||
i := strings.IndexByte(address, '%')
|
||||
if i != -1 {
|
||||
address = address[:i]
|
||||
idx := strings.IndexByte(address, '%')
|
||||
if idx != -1 {
|
||||
address = address[:idx]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
@@ -10,27 +10,37 @@ import (
|
||||
|
||||
func TestFindClientIPWithoutHeaders(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "@"}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: ""}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +51,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -50,7 +60,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
|
||||
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -59,7 +69,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -68,7 +78,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "70.41.3.18")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "70.41.3.18" {
|
||||
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -77,7 +87,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "fake IP")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +97,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
|
||||
headers.Set("X-Real-Ip", "192.168.122.1")
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "192.168.122.1" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -99,15 +109,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
|
||||
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "@"}
|
||||
|
||||
if ip := FindClientIP(r); ip != "@" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,62 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
|
||||
|
||||
r := &http.Request{RemoteAddr: "@", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedIP(t *testing.T) {
|
||||
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
|
||||
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"10.0.0.1", true},
|
||||
{"::1", true},
|
||||
{"192.168.1.1", false},
|
||||
{"invalid", false},
|
||||
{"@", false},
|
||||
{"/tmp/miniflux.sock", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
result := IsTrustedIP(scenario.ip, trustedNetworks)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", nil) {
|
||||
t.Error("Expected false when no trusted networks are defined")
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", []string{}) {
|
||||
t.Error("Expected false when trusted networks list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteIP(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected string
|
||||
}{
|
||||
{"192.168.0.1:4242", "192.168.0.1"},
|
||||
{"[2001:db8::1]:4242", "2001:db8::1"},
|
||||
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
|
||||
{"", "127.0.0.1"},
|
||||
{"@", "127.0.0.1"},
|
||||
{"invalid", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
r := &http.Request{RemoteAddr: scenario.ip}
|
||||
result := FindRemoteIP(r)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -20,51 +19,60 @@ const (
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
UserSessionTokenContextKey
|
||||
UserLanguageContextKey
|
||||
UserThemeContextKey
|
||||
SessionIDContextKey
|
||||
CSRFContextKey
|
||||
OAuth2StateContextKey
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
LastForceRefreshContextKey
|
||||
WebSessionContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GoogleReaderToken returns the google reader token if it exists.
|
||||
// GoogleReaderToken returns the Google Reader token from the request context, if present.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
// IsAdminUser reports whether the logged-in user is an administrator.
|
||||
func IsAdminUser(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAdminUserContextKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
// 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 UserID of the logged user.
|
||||
// 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 username of the logged user.
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
func UserName(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserNameContextKey)
|
||||
if value == "" {
|
||||
@@ -73,7 +81,7 @@ func UserName(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
|
||||
func UserTimezone(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserTimezoneContextKey)
|
||||
if value == "" {
|
||||
@@ -82,69 +90,7 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage get the locale used by the current logged user.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
language = "en_US"
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme get the theme used by the current logged user.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
theme = "system_serif"
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the current CSRF token.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the current OAuth2 state.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the message message if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the message error message if any.
|
||||
func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
func LastForceRefresh(r *http.Request) int64 {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the context.
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"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()
|
||||
@@ -168,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) {
|
||||
@@ -190,6 +207,39 @@ 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) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserName(r)
|
||||
expected := "unknown"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserName(r)
|
||||
expected = "jane"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTimezone(t *testing.T) {
|
||||
@@ -214,179 +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 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)
|
||||
result := WebSession(r)
|
||||
if result == nil || result.ID != "session-id" {
|
||||
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,3 +303,25 @@ func TestClientIP(t *testing.T) {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleReaderToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := GoogleReaderToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = GoogleReaderToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CookieValue returns the cookie value.
|
||||
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// FormInt64Value returns a form value as integer.
|
||||
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
|
||||
func FormInt64Value(r *http.Request, param string) int64 {
|
||||
value := r.FormValue(param)
|
||||
integer, err := strconv.ParseInt(value, 10, 64)
|
||||
@@ -22,10 +20,9 @@ func FormInt64Value(r *http.Request, param string) int64 {
|
||||
return integer
|
||||
}
|
||||
|
||||
// RouteInt64Param returns an URL route parameter as 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
|
||||
}
|
||||
@@ -37,13 +34,12 @@ func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// RouteStringParam returns a URL route parameter as string.
|
||||
// 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 a query string parameter as string.
|
||||
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
|
||||
func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -52,7 +48,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryStringParamList returns all values associated to the parameter.
|
||||
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
|
||||
func QueryStringParamList(r *http.Request, param string) []string {
|
||||
var results []string
|
||||
values := r.URL.Query()
|
||||
@@ -69,7 +65,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
|
||||
return results
|
||||
}
|
||||
|
||||
// QueryIntParam returns a query string parameter as integer.
|
||||
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
|
||||
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -88,7 +84,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
return int(val)
|
||||
}
|
||||
|
||||
// QueryInt64Param returns a query string parameter as int64.
|
||||
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
|
||||
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -107,7 +103,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
return val
|
||||
}
|
||||
|
||||
// QueryBoolParam returns a query string parameter as bool.
|
||||
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
|
||||
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -123,9 +119,13 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
// HasQueryParam reports whether the query string contains the named parameter.
|
||||
func HasQueryParam(r *http.Request, param string) bool {
|
||||
values := r.URL.Query()
|
||||
_, ok := values[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
func routeParam(r *http.Request, param string) string {
|
||||
return r.PathValue(param)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestFormInt64Value(t *testing.T) {
|
||||
@@ -41,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"
|
||||
|
||||
@@ -59,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)
|
||||
}
|
||||
@@ -68,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)
|
||||
|
||||
@@ -100,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)
|
||||
}
|
||||
@@ -179,7 +178,7 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryInt64Param(r, "invalid", int64(69))
|
||||
result = QueryInt64Param(r, "negative", int64(69))
|
||||
expected = int64(69)
|
||||
|
||||
if result != expected {
|
||||
@@ -194,6 +193,58 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBoolParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryBoolParam(r, "truthy", false)
|
||||
expected := true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "falsy", true)
|
||||
expected = false
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "missing", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "invalid", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryStringParamList(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryStringParamList(r, "tag")
|
||||
expected := []string{"alpha", "beta", "gamma"}
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryStringParamList(r, "missing")
|
||||
expected = nil
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasQueryParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?key=42")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
@@ -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()
|
||||
@@ -84,8 +113,6 @@ func (b *Builder) Write() {
|
||||
b.compress(v)
|
||||
case string:
|
||||
b.compress([]byte(v))
|
||||
case error:
|
||||
b.compress([]byte(v.Error()))
|
||||
case io.Reader:
|
||||
// Compression not implemented in this case
|
||||
b.writeHeaders()
|
||||
@@ -97,44 +124,43 @@ 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)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
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)
|
||||
defer gzipWriter.Close()
|
||||
gzipWriter.Write(data)
|
||||
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)
|
||||
defer flateWriter.Close()
|
||||
flateWriter.Write(data)
|
||||
flateWriter.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -143,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,7 +4,8 @@
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -21,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)
|
||||
@@ -49,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)
|
||||
@@ -70,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)
|
||||
@@ -92,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)
|
||||
@@ -105,7 +106,7 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithError(t *testing.T) {
|
||||
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -114,15 +115,78 @@ func TestBuildResponseWithError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(errors.New("Some error")).Write()
|
||||
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,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)
|
||||
@@ -156,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()
|
||||
})
|
||||
})
|
||||
@@ -176,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,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)
|
||||
@@ -263,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)
|
||||
@@ -287,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)
|
||||
@@ -298,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) {
|
||||
@@ -311,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)
|
||||
@@ -322,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) {
|
||||
@@ -335,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)
|
||||
@@ -346,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) {
|
||||
@@ -358,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)
|
||||
@@ -369,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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user